refactor: v2.0 全量重构 — TypeScript + React + Zustand + IndexedDB

技术栈升级:
- JavaScript → TypeScript 5.6(全量类型安全)
- 原生 DOM → React 18(函数组件 + Hooks)
- 全局变量 → Zustand 5(轻量状态管理)
- localStorage → IndexedDB / Dexie.js 4(大容量、异步、索引)
- marked.js → unified / remark / rehype(插件化渲染管线)
- 无打包 → electron-vite 3(HMR 热更新)
- 纯 CSS → CSS Variables + CSS Modules

新增功能:
- 标签页状态 IndexedDB 持久化(关闭后可恢复)
- 最近打开文件列表
- 大文件虚拟化行号(>2000 行)
- 搜索高亮二分查找优化 O(log N)
- rehype-sanitize HTML 安全过滤

文件结构:
- 7 个源文件 → 51 个模块化文件
- src/main/     主进程(6 文件)
- src/preload/  预加载(1 文件)
- src/renderer/ 渲染进程(42 文件:组件/hooks/stores/lib/db/types/styles)
- src/shared/   共享类型(2 文件)

构建验证:
- TypeScript 检查零错误
- electron-vite build 成功
- 产物:main 15kB + preload 2kB + renderer 1.5MB
This commit is contained in:
thzxx
2026-05-27 20:29:23 +08:00
parent c2cdba546b
commit 5e1c89d280
59 changed files with 4674 additions and 314 deletions
+21
View File
@@ -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'
}
}
+7
View File
@@ -24,3 +24,10 @@ out/
# Logs
*.log
npm-debug.log*
# TypeScript
*.tsbuildinfo
# Vite
.vite/
.npmrc
+447 -242
View File
@@ -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/)与 UIcomponents/)解耦,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
└────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────
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 (renderer/) │
- 多标签页管理
- UI 渲染(亮色/暗色主题)
- Markdown 编辑与预览
- 搜索替换(高亮、正则、大小写)
- 文件树侧边栏
- 拖拽文件处理
- 快捷键处理
- 用户设置持久化 (localStorage)
─────────────────────────────────────────┘
┌───────────────────────▼──────────────────────────────────────┐
│ 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<string> // 已展开的目录集合
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<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
// ... 共 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 解析为 MDASTMarkdown AST
remark-gfm 扩展 GFM 语法(表格、任务列表、删除线)
remark-rehype 转换为 HASTHTML 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 内联元素
- GFMGitHub 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 和 GFMGitHub 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 devHMR 热更新)
```
### 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 规则 |
+154 -53
View File
@@ -11,13 +11,15 @@
<p align="center">
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-1.2.0-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Version-2.0.0-orange?style=flat-square" alt="Version">
</p>
<p align="center">
基于 Electron 构建,开箱即用的 Markdown 桌面编辑器。<br>
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树。
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化
</p>
---
@@ -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 # 开发环境配置指南
├── REFACTOR-PLAN.md # 重构方案文档
├── LICENSE # MIT 许可证
└── README.md # 项目说明
└── README.md # 本文件
```
## 🏗️ 架构设计
```
┌──────────────────────────────────────────┐
│ Main Process (main.js)
│ 窗口管理 · 文件系统 · 文件监听 · IPC │
│ 目录树读取 · 侧边栏目录监听 │
└──────────────┬───────────────────────────┘
┌─────────────────────────────────────────────────────────────
Main Process (src/main/)
│ 窗口管理 · 文件系统 · 文件监听 · IPC · 目录树 · 单实例锁
└──────────────────────┬──────────────────────────────────────┘
│ contextBridge (安全隔离)
┌─────────────────────────────────────────┐
│ Preload Script (preload.js)
│ 安全 API 桥接层 │
└─────────────────────────────────────────┘
┌──────────────────────▼──────────────────────────────────────┐
Preload Script (src/preload/)
类型安全 API 桥接层
└──────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Renderer Process (renderer/) │
标签页 · 编辑器 · 预览 · 暗色主题
搜索替换 · 文件树侧边栏 · 拖拽 · 快捷键
localStorage
└──────────────────────────────────────────┘
```
┌──────────────────────▼──────────────────────────────────────┐
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+ 语言
- ✅ 行内代码
- ✅ 链接 / 图片
- ✅ 链接 / 图片(支持相对路径)
- ✅ 表格
- ✅ 引用块
- ✅ 水平线
+40
View File
@@ -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')
}
}
}
})
+35 -12
View File
@@ -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": {
+75
View File
@@ -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<FileNode[]> {
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'
}
+83
View File
@@ -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
}
}
+129
View File
@@ -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()
}
})
}
+23
View File
@@ -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
+183
View File
@@ -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, () => {
// 重置关闭状态
})
}
+59
View File
@@ -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
}
+47
View File
@@ -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)
})
+208
View File
@@ -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<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(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 (
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile}
onSave={handleSave}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
darkMode={darkMode}
onToggleDark={toggleDarkMode}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{showModifiedBanner && (
<ModifiedBanner
onReload={() => {
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 ? (
<div id="content-wrapper">
{viewMode !== 'preview' && (
<div id="editor-panel" style={editorStyle}>
<SearchBar />
<Editor />
</div>
)}
{viewMode !== 'editor' && <div id="resizer" />}
{viewMode !== 'editor' && (
<div id="preview-panel" style={previewStyle}>
<Preview />
</div>
)}
</div>
) : (
<WelcomeScreen
onOpen={handleOpenFile}
onNew={() => createTab(null, '')}
/>
)}
</div>
</div>
<StatusBar />
{toastMessage && <Toast message={toastMessage} />}
<DropOverlay />
</div>
)
}
@@ -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 (
<div id="drop-overlay">
<div className="drop-content">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<p></p>
</div>
</div>
)
}
+210
View File
@@ -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<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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 (
<>
<div style={{ height: topPadding }} />
{Array.from({ length: endLine - startLine }, (_, i) => (
<div key={startLine + i} className="line-num" style={{ height: lineHeight }}>
{startLine + i + 1}
</div>
))}
<div style={{ height: bottomPadding }} />
</>
)
}
return Array.from({ length: lineCount }, (_, i) => (
<div key={i} className="line-num" style={{ height: lineHeight }}>
{i + 1}
</div>
))
}
return (
<div id="editor-wrapper" ref={wrapperRef}>
<div id="line-numbers" ref={lineNumbersRef}>
{renderLineNumbers()}
</div>
<textarea
ref={textareaRef}
id="editor"
spellCheck={false}
placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."
onInput={handleInput}
onScroll={handleScroll}
onClick={updateCursorPos}
onKeyUp={updateCursorPos}
onKeyDown={handleKeyDown}
/>
</div>
)
}
@@ -0,0 +1,16 @@
import React from 'react'
interface ModifiedBannerProps {
onReload: () => void
onDismiss: () => void
}
export function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
return (
<div id="modified-banner">
<span></span>
<button className="banner-btn" onClick={onReload}></button>
<button className="banner-btn" onClick={onDismiss}></button>
</div>
)
}
@@ -0,0 +1,75 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { renderMarkdown } from '../../lib/markdown'
export function Preview() {
const [html, setHtml] = useState('')
const previewRef = useRef<HTMLDivElement>(null)
const getActiveTab = useTabStore(s => s.getActiveTab)
const activeTabId = useTabStore(s => s.activeTabId)
// 渲染 Markdown
useEffect(() => {
const tab = getActiveTab()
if (!tab) {
setHtml('')
return
}
let cancelled = false
renderMarkdown(tab.content).then(result => {
if (!cancelled) {
setHtml(result)
}
})
return () => { cancelled = true }
}, [activeTabId, getActiveTab])
// 监听编辑器内容变化
useEffect(() => {
const timer = setInterval(() => {
const tab = getActiveTab()
if (!tab) return
renderMarkdown(tab.content).then(result => {
setHtml(result)
})
}, 300)
return () => clearInterval(timer)
}, [getActiveTab])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent) => {
const link = (e.target as HTMLElement).closest('a')
if (!link) return
e.preventDefault()
const href = link.getAttribute('href')
if (!href) return
if (href.startsWith('#')) {
const target = previewRef.current?.querySelector(href)
if (target) target.scrollIntoView({ behavior: 'smooth' })
return
}
try {
const url = new URL(href)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
} catch {
return
}
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
} else {
window.open(href, '_blank', 'noopener')
}
}, [])
return (
<div
ref={previewRef}
id="preview"
className="markdown-body"
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
@@ -0,0 +1,156 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { useSearchStore } from '../../stores/searchStore'
import { useTabStore } from '../../stores/tabStore'
import { findMatches } from '../../lib/searchEngine'
export function SearchBar() {
const searchInputRef = useRef<HTMLInputElement>(null)
const isVisible = useSearchStore(s => s.isVisible)
const showReplace = useSearchStore(s => s.showReplace)
const searchText = useSearchStore(s => s.searchText)
const replaceText = useSearchStore(s => s.replaceText)
const matches = useSearchStore(s => s.matches)
const currentIndex = useSearchStore(s => s.currentIndex)
const options = useSearchStore(s => s.options)
const setSearchText = useSearchStore(s => s.setSearchText)
const setReplaceText = useSearchStore(s => s.setReplaceText)
const setShowReplace = useSearchStore(s => s.setShowReplace)
const setMatches = useSearchStore(s => s.setMatches)
const findNext = useSearchStore(s => s.findNext)
const findPrev = useSearchStore(s => s.findPrev)
const toggleCaseSensitive = useSearchStore(s => s.toggleCaseSensitive)
const toggleRegex = useSearchStore(s => s.toggleRegex)
const close = useSearchStore(s => s.close)
const getActiveTab = useTabStore(s => s.getActiveTab)
// 执行搜索
const doSearch = useCallback((text: string) => {
const tab = getActiveTab()
if (!tab || !text) {
setMatches([])
return
}
const found = findMatches(tab.content, text, options)
setMatches(found)
}, [getActiveTab, options, setMatches])
// 搜索文本变化
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value
setSearchText(text)
doSearch(text)
}, [setSearchText, doSearch])
// 替换当前
const handleReplaceCurrent = useCallback(() => {
const tab = getActiveTab()
if (!tab || matches.length === 0 || currentIndex < 0) return
const m = matches[currentIndex]
const newContent = tab.content.substring(0, m.start) + replaceText + tab.content.substring(m.end)
useTabStore.getState().updateTabContent(tab.id, newContent)
// 重新搜索
doSearch(searchText)
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
// 全部替换
const handleReplaceAll = useCallback(() => {
const tab = getActiveTab()
if (!tab || matches.length === 0) return
let result = ''
let lastEnd = 0
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i]
result = tab.content.substring(lastEnd, m.start) + replaceText + result
lastEnd = m.end
}
result = tab.content.substring(0, matches[0].start) + result
useTabStore.getState().updateTabContent(tab.id, result)
doSearch(searchText)
}, [getActiveTab, matches, replaceText, searchText, doSearch])
// 打开时聚焦
useEffect(() => {
if (isVisible && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [isVisible])
if (!isVisible) return null
return (
<div id="search-bar">
<div className="search-row">
<button
id="btn-toggle-replace"
className={`search-opt-btn ${showReplace ? 'expanded' : ''}`}
onClick={() => setShowReplace(!showReplace)}
title="展开替换行"
>
</button>
<input
ref={searchInputRef}
type="text"
id="search-input"
placeholder="查找..."
value={searchText}
onChange={handleSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.shiftKey ? findPrev() : findNext()
}
if (e.key === 'Escape') {
e.preventDefault()
close()
}
}}
/>
<span id="search-count">
{matches.length > 0 ? `${currentIndex + 1}/${matches.length}` : searchText ? '无结果' : ''}
</span>
<button
className={`search-opt-btn ${options.caseSensitive ? 'active' : ''}`}
onClick={toggleCaseSensitive}
title="区分大小写 (Alt+C)"
>
Aa
</button>
<button
className={`search-opt-btn ${options.useRegex ? 'active' : ''}`}
onClick={toggleRegex}
title="正则表达式 (Alt+R)"
>
.*
</button>
<button className="search-nav-btn" onClick={findPrev} title="上一个 (Shift+Enter)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button className="search-nav-btn" onClick={findNext} title="下一个 (Enter)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<button className="search-nav-btn" onClick={close} title="关闭 (Escape)"></button>
</div>
{showReplace && (
<div id="replace-row">
<input
type="text"
id="replace-input"
placeholder="替换..."
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
/>
<button className="replace-btn" onClick={handleReplaceCurrent} title="替换 (Ctrl+Shift+G)"></button>
<button className="replace-btn" onClick={handleReplaceAll} title="全部替换 (Ctrl+Shift+H)"></button>
</div>
)}
</div>
)
}
+220
View File
@@ -0,0 +1,220 @@
import React, { useEffect, useCallback, useState, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import type { FileNode } from '../../types/file'
export function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const switchToTab = useTabStore(s => s.switchToTab)
const createTab = useTabStore(s => s.createTab)
const rootPath = useSidebarStore(s => s.rootPath)
const tree = useSidebarStore(s => s.tree)
const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const setRootPath = useSidebarStore(s => s.setRootPath)
const setTree = useSidebarStore(s => s.setTree)
const isVisible = useSidebarStore(s => s.isVisible)
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null)
// 打开文件夹
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const dirPath = await window.electronAPI.openFolderDialog()
if (dirPath) {
setRootPath(dirPath)
const result = await window.electronAPI.readDirTree(dirPath)
if (result.success && result.tree) {
setTree(result.tree)
window.electronAPI.watchDir(dirPath)
}
}
}, [setRootPath, setTree])
// 刷新目录树
const refreshTree = useCallback(async () => {
if (!rootPath || !window.electronAPI) return
const result = await window.electronAPI.readDirTree(rootPath)
if (result.success && result.tree) {
setTree(result.tree)
}
}, [rootPath, setTree])
// 目录变化监听
useEffect(() => {
if (!window.electronAPI) return
window.electronAPI.onDirChanged(() => {
refreshTree()
})
return () => {
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
}
}, [refreshTree])
// 侧边栏宽度调节
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
if (sidebarRef.current) {
const newWidth = Math.max(180, Math.min(500, e.clientX))
sidebarRef.current.style.width = newWidth + 'px'
}
}
const handleMouseUp = () => setIsResizing(false)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing])
// 独立文件(不在当前文件夹内的已打开文件)
const independentFiles = tabs.filter(t => {
if (!t.filePath) return false
if (!rootPath) return true
return !t.filePath.startsWith(rootPath)
})
if (!isVisible) return null
return (
<div id="sidebar" ref={sidebarRef}>
<div id="sidebar-header">
<span id="sidebar-title"></span>
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</button>
</div>
<div id="sidebar-tree">
{/* 独立文件区 */}
{independentFiles.length > 0 && (
<div className="independent-files-section">
<div className="independent-files-header"></div>
{independentFiles.map(tab => (
<div
key={tab.id}
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
style={{ paddingLeft: '8px' }}
onClick={() => switchToTab(tab.id)}
>
<span className="tree-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
<span className="tree-name">{getFileName(tab.filePath!)}</span>
{tab.isModified && <span className="independent-modified-dot"> </span>}
</div>
))}
</div>
)}
{/* 文件夹目录树 */}
{rootPath && (
<>
<div className="sidebar-section-header"></div>
<FileTree
nodes={tree}
depth={0}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeTabId={activeTabId}
onFileClick={async (path) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) {
switchToTab(existing.id)
} else if (window.electronAPI) {
const result = await window.electronAPI.readFile(path)
if (result.success && result.content) {
createTab(path, result.content)
}
}
}}
/>
</>
)}
</div>
{/* 调节手柄 */}
<div
className="sidebar-resize-handle"
onMouseDown={() => setIsResizing(true)}
/>
</div>
)
}
// 文件树递归组件
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, onFileClick }: {
nodes: FileNode[]
depth: number
expandedDirs: Set<string>
toggleDir: (path: string) => void
activeTabId: string | null
onFileClick: (path: string) => void
}) {
return (
<>
{nodes.map(node => (
<React.Fragment key={node.path}>
<div
className={`tree-item ${node.type === 'file' && activeTabId ? '' : ''}`}
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
onClick={() => {
if (node.type === 'dir') {
toggleDir(node.path)
} else {
onFileClick(node.path)
}
}}
>
{node.type === 'dir' ? (
<>
<span className={`tree-arrow ${expandedDirs.has(node.path) ? 'expanded' : ''}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="9 18 15 12 9 6" />
</svg>
</span>
<span className="tree-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</span>
</>
) : (
<>
<span style={{ width: '16px', flexShrink: 0 }} />
<span className="tree-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
</>
)}
<span className="tree-name">{node.name}</span>
</div>
{node.type === 'dir' && expandedDirs.has(node.path) && node.children && (
<FileTree
nodes={node.children}
depth={depth + 1}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeTabId={activeTabId}
onFileClick={onFileClick}
/>
)}
</React.Fragment>
))}
</>
)
}
@@ -0,0 +1,23 @@
import React from 'react'
import { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils'
export function StatusBar() {
const getActiveTab = useTabStore(s => s.getActiveTab)
const tab = getActiveTab()
return (
<div id="statusbar">
<div className="status-left">
<span id="status-text">
{tab ? (tab.filePath ? getFileName(tab.filePath) : '未命名') : '就绪'}
</span>
</div>
<div className="status-right">
<span id="status-encoding">UTF-8</span>
<span className="status-divider">|</span>
<span id="status-lang">Markdown</span>
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import React, { useCallback } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils'
export function TabBar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const switchToTab = useTabStore(s => s.switchToTab)
const closeTab = useTabStore(s => s.closeTab)
const createTab = useTabStore(s => s.createTab)
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
}
closeTab(tabId)
}, [tabs, closeTab])
if (tabs.length === 0) return null
return (
<div id="tab-bar">
<div id="tab-list">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
onClick={() => switchToTab(tab.id)}
>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
</span>
<button
className="tab-close"
onClick={(e) => handleClose(e, tab.id)}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
<button
className="tab-add-btn"
onClick={() => createTab(null, '')}
title="新建标签页 (Ctrl+T)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
interface ToastProps {
message: string
}
export function Toast({ message }: ToastProps) {
return (
<div id="toast-notification" className="show">
{message}
</div>
)
}
@@ -0,0 +1,76 @@
import React from 'react'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
viewMode: 'split' | 'editor' | 'preview'
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void
darkMode: boolean
onToggleDark: () => void
}
export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark }: ToolbarProps) {
return (
<div id="toolbar">
<div className="toolbar-left">
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span></span>
</button>
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
<span></span>
</button>
<div className="toolbar-divider" />
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
<span></span>
</button>
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
<span></span>
</button>
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="纯预览 (Ctrl+3)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span></span>
</button>
</div>
<div className="toolbar-right">
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题">
{darkMode ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import React from 'react'
interface WelcomeScreenProps {
onOpen: () => void
onNew: () => void
}
export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) {
return (
<div id="welcome-screen">
<div className="welcome-content">
<div className="welcome-icon">
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="5" width="80" height="90" rx="8" fill="#f0f6ff" stroke="#1a73e8" strokeWidth="2" />
<text x="50" y="45" textAnchor="middle" fill="#1a73e8" fontFamily="system-ui" fontWeight="bold" fontSize="32">M</text>
<text x="50" y="70" textAnchor="middle" fill="#5f6368" fontFamily="system-ui" fontSize="12">MarkLite</text>
</svg>
</div>
<h1>使 MarkLite</h1>
<p> Markdown </p>
<div className="welcome-actions">
<button className="welcome-btn primary" onClick={onOpen}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</button>
<button className="welcome-btn secondary" onClick={onNew}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
<div className="welcome-tips">
<p>💡 .md </p>
<p> Ctrl+O | Ctrl+S | Ctrl+F | Ctrl+1/2/3 </p>
</div>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { db } from './schema'
export const recentFilesRepository = {
async add(filePath: string): Promise<void> {
const existing = await db.recentFiles.where('filePath').equals(filePath).first()
if (existing) {
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
} else {
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
}
},
async getAll(limit = 20): Promise<string[]> {
const files = await db.recentFiles
.orderBy('lastOpened')
.reverse()
.limit(limit)
.toArray()
return files.map((f: { filePath: string; lastOpened: number }) => f.filePath)
},
async remove(filePath: string): Promise<void> {
await db.recentFiles.where('filePath').equals(filePath).delete()
},
async clear(): Promise<void> {
await db.recentFiles.clear()
}
}
+43
View File
@@ -0,0 +1,43 @@
import Dexie, { type EntityTable } from 'dexie'
export interface TabSnapshot {
id: string
filePath: string | null
content: string
scrollTop: number
scrollLeft: number
selectionStart: number
selectionEnd: number
previewScrollTop: number
isModified: boolean
updatedAt: number
}
export interface SettingsRecord {
id: string // 固定为 'default'
darkMode: boolean
viewMode: 'split' | 'editor' | 'preview'
splitRatio: number
sidebarCollapsed: boolean
sidebarWidth: number
}
export interface RecentFile {
id?: number
filePath: string
lastOpened: number
}
const db = new Dexie('MarkLite') as Dexie & {
tabSnapshots: EntityTable<TabSnapshot, 'id'>
settings: EntityTable<SettingsRecord, 'id'>
recentFiles: EntityTable<RecentFile, 'id'>
}
db.version(1).stores({
tabSnapshots: 'id, filePath, updatedAt',
settings: 'id',
recentFiles: '++id, filePath, lastOpened'
})
export { db }
+29
View File
@@ -0,0 +1,29 @@
import { db, type SettingsRecord } from './schema'
import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
export const settingsRepository = {
async load(): Promise<Settings> {
try {
const record = await db.settings.get('default')
if (record) {
return {
darkMode: record.darkMode,
viewMode: record.viewMode,
splitRatio: record.splitRatio,
sidebarCollapsed: record.sidebarCollapsed,
sidebarWidth: record.sidebarWidth
}
}
} catch {
// fallback
}
return { ...DEFAULT_SETTINGS }
},
async save(settings: Partial<Settings>): Promise<void> {
const current = await this.load()
const merged = { ...current, ...settings }
const record: SettingsRecord = { id: 'default', ...merged }
await db.settings.put(record)
}
}
+18
View File
@@ -0,0 +1,18 @@
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()
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants'
export function useDragDrop(showToast: (msg: string) => void) {
const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
const files = e.dataTransfer?.files
if (!files) return
let rejected = 0
for (const file of Array.from(files)) {
const filePath = (file as File & { path?: string }).path || file.name
if (!isAllowedFile(filePath)) {
rejected++
continue
}
if (file.size > MAX_FILE_SIZE) {
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
continue
}
if (window.electronAPI) {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content) {
createTab(filePath, result.content)
}
} else {
const reader = new FileReader()
reader.onload = (ev) => {
const content = ev.target?.result as string
createTab(file.name, content)
}
reader.readAsText(file)
}
}
if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
}
}, [createTab, showToast])
useEffect(() => {
const prevent = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
document.addEventListener('dragenter', prevent)
document.addEventListener('dragleave', prevent)
document.addEventListener('dragover', prevent)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', prevent)
document.removeEventListener('dragleave', prevent)
document.removeEventListener('dragover', prevent)
document.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSearchStore } from '../stores/searchStore'
import { useEditorStore } from '../stores/editorStore'
import { findMatches } from '../lib/searchEngine'
import type { SearchMatch } from '../types/search'
export function useFileWatch() {
const getActiveTab = useTabStore(s => s.getActiveTab)
useEffect(() => {
if (!window.electronAPI) return
window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab()
if (tab && tab.filePath === filePath) {
// 显示修改横幅(通过状态管理触发 UI 更新)
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
}
})
return () => {
window.electronAPI?.removeAllListeners('file:externallyModified')
}
}, [getActiveTab])
}
// 搜索 Hook
export function useSearch() {
const store = useSearchStore()
const doSearch = (content: string) => {
if (!store.searchText) {
store.setMatches([])
return
}
const matches = findMatches(content, store.searchText, store.options)
store.setMatches(matches)
if (matches.length > 0) {
// 找到离光标最近的匹配
store.setCurrentIndex(0)
}
}
const replaceCurrent = (content: string): string | null => {
if (store.matches.length === 0 || store.currentIndex < 0) return null
const m = store.matches[store.currentIndex]
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
}
const replaceAll = (content: string): string => {
if (store.matches.length === 0) return content
let result = ''
let lastEnd = 0
// 从后往前替换
for (let i = store.matches.length - 1; i >= 0; i--) {
const m = store.matches[i]
result = content.substring(lastEnd, m.start) + store.replaceText + result
lastEnd = m.end
}
result = content.substring(0, store.matches[0].start) + result
return result
}
return { ...store, doSearch, replaceCurrent, replaceAll }
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { useSearchStore } from '../stores/searchStore'
import type { ViewMode } from '../types/settings'
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const createTab = useTabStore(s => s.createTab)
const closeTab = useTabStore(s => s.closeTab)
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const switchToTab = useTabStore(s => s.switchToTab)
const mruStack = useTabStore(s => s.mruStack)
const setViewMode = useEditorStore(s => s.setViewMode)
const searchStore = useSearchStore
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') {
e.preventDefault()
handleOpenFile()
return
}
if (isCtrl && e.key === 's' && !e.shiftKey) {
e.preventDefault()
handleSave()
return
}
if (isCtrl && e.shiftKey && e.key === 'S') {
e.preventDefault()
handleSaveAs()
return
}
if (isCtrl && e.key === '1') {
e.preventDefault()
setViewMode('split')
return
}
if (isCtrl && e.key === '2') {
e.preventDefault()
setViewMode('editor')
return
}
if (isCtrl && e.key === '3') {
e.preventDefault()
setViewMode('preview')
return
}
if (isCtrl && e.key === 't') {
e.preventDefault()
createTab(null, '')
return
}
if (isCtrl && e.key === 'w') {
e.preventDefault()
if (activeTabId) closeTab(activeTabId)
return
}
if (isCtrl && e.key === 'Tab') {
e.preventDefault()
if (tabs.length > 1) {
if (e.shiftKey) {
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
switchToTab(targetId)
}
}
} else {
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = (idx + 1) % tabs.length
switchToTab(tabs[next].id)
}
}
return
}
// Search & Replace
const state = searchStore.getState()
if (isCtrl && e.key === 'f') {
e.preventDefault()
state.setVisible(true)
state.setShowReplace(false)
return
}
if (isCtrl && e.key === 'h') {
e.preventDefault()
state.setVisible(true)
state.setShowReplace(true)
return
}
if (e.key === 'Escape' && state.isVisible) {
e.preventDefault()
state.close()
return
}
if (e.altKey && e.key === 'c') {
e.preventDefault()
state.toggleCaseSensitive()
return
}
if (e.altKey && e.key === 'r') {
e.preventDefault()
state.toggleRegex()
return
}
if (isCtrl && e.shiftKey && e.key === 'G') {
e.preventDefault()
state.findPrev()
return
}
if (isCtrl && e.shiftKey && e.key === 'H') {
e.preventDefault()
// replaceAll 需要外部实现
return
}
}, [handleOpenFile, handleSave, handleSaveAs, createTab, closeTab, activeTabId, tabs, switchToTab, mruStack, setViewMode, searchStore])
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}, [handleKeydown])
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ViewMode } from '../types/settings'
export function useSettings() {
const viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio)
const setViewMode = useEditorStore(s => s.setViewMode)
const setSplitRatio = useEditorStore(s => s.setSplitRatio)
// 初始化
useEffect(() => {
settingsRepository.load().then(settings => {
setViewMode(settings.viewMode)
setSplitRatio(settings.splitRatio)
})
}, [setViewMode, setSplitRatio])
const saveViewMode = (mode: ViewMode) => {
setViewMode(mode)
settingsRepository.save({ viewMode: mode })
}
const saveSplitRatio = (ratio: number) => {
setSplitRatio(ratio)
settingsRepository.save({ splitRatio: ratio })
}
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode)
const setDarkMode = useEditorStore(s => s.setDarkMode)
// 初始化:从 IndexedDB 加载主题设置
useEffect(() => {
settingsRepository.load().then(settings => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
})
}, [setDarkMode])
// 应用主题到 DOM
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode })
}, [darkMode])
return { darkMode, toggleDarkMode: useEditorStore(s => s.toggleDarkMode) }
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect } from 'react'
export function useUnsavedWarning(hasUnsaved: () => boolean) {
useEffect(() => {
if (!window.electronAPI) {
const handler = (e: BeforeUnloadEvent) => {
if (hasUnsaved()) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}
const api = window.electronAPI
api.onConfirmClose(() => {
if (!hasUnsaved()) {
api.forceClose()
return
}
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) {
api.forceClose()
} else {
api.cancelClose()
}
})
return () => {
api.removeAllListeners('window:confirmClose')
}
}, [hasUnsaved])
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: file:; object-src 'none'; base-uri 'self'; form-action 'self';" />
<title>MarkLite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
// 常量定义
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
export const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt'])
export const UPDATE_DEBOUNCE_MS = 150
export const LARGE_FILE_LINE_THRESHOLD = 2000
export const CLOSE_TIMEOUT_MS = 5000
export const WATCH_RESTART_DELAY_MS = 300
export const DIR_REFRESH_DEBOUNCE_MS = 300
+20
View File
@@ -0,0 +1,20 @@
import { ALLOWED_EXTENSIONS } from './constants'
export function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
export function getFileName(filePath: string): string {
return filePath.split(/[/\\]/).pop() || filePath
}
export function isAllowedFile(filePath: string): boolean {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
}
export function getFileExtension(filePath: string): string {
return filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
}
+32
View File
@@ -0,0 +1,32 @@
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img ?? []), ['src']]
}
})
.use(rehypeHighlight)
.use(rehypeStringify)
export async function renderMarkdown(content: string): Promise<string> {
try {
const result = await processor.process(content)
return String(result)
} catch (e) {
return `<p style="color:red">渲染错误: ${e instanceof Error ? e.message : String(e)}</p>`
}
}
+109
View File
@@ -0,0 +1,109 @@
// 滚动同步算法 — 基于块级元素 DOM 位置映射
import type { SearchMatch } from '../types/search'
export interface ScrollMap {
lineToPreviewMap: Float32Array
}
// 识别块级元素起始行
function identifyBlockStarts(lines: string[]): Set<number> {
const starts = new Set<number>()
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trimStart()
// 围栏代码块边界
if (line.startsWith('```')) {
if (!inCodeBlock) starts.add(i)
inCodeBlock = !inCodeBlock
continue
}
if (inCodeBlock) continue
// 块级元素起始行
if (
line.startsWith('#') ||
line.startsWith('- ') ||
line.startsWith('* ') ||
line.startsWith('+ ') ||
/^\d+\.\s/.test(line) ||
line.startsWith('> ') ||
line.startsWith('|') ||
line.startsWith('---') ||
line.startsWith('***') ||
line.startsWith('___') ||
(line === '' && i > 0 && lines[i - 1].trim() === '')
) {
starts.add(i)
}
}
return starts
}
export function buildScrollMap(
markdown: string,
previewContainer: HTMLElement
): ScrollMap {
const lines = markdown.split('\n')
const totalLines = lines.length
const lineToPreviewMap = new Float32Array(totalLines)
// 1. 识别块级元素起始行
const blockStarts = identifyBlockStarts(lines)
// 2. 获取预览 DOM 元素位置
const previewPositions: number[] = []
for (let i = 0; i < previewContainer.children.length; i++) {
previewPositions.push((previewContainer.children[i] as HTMLElement).offsetTop)
}
if (previewPositions.length === 0 || blockStarts.size === 0) {
// fallback: 线性映射
const lastPos = previewPositions[previewPositions.length - 1] || 0
for (let i = 0; i < totalLines; i++) {
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * lastPos
}
return { lineToPreviewMap }
}
// 3. 块起始行 → 预览位置映射
const uniqueStartsArr = [...blockStarts].sort((a, b) => a - b)
const uniqueStartsSet = new Set(uniqueStartsArr)
const domCount = previewPositions.length
for (let i = 0; i < uniqueStartsArr.length; i++) {
const lineIdx = uniqueStartsArr[i]
const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1)
lineToPreviewMap[lineIdx] = previewPositions[domIdx]
}
// 4. 线性插值填充所有行
for (let i = 0; i < totalLines; i++) {
if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue
// 找到前后最近的已映射行
let prevLine = -1
let nextLine = -1
for (let j = i - 1; j >= 0; j--) {
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break }
}
for (let j = i + 1; j < totalLines; j++) {
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break }
}
if (prevLine === -1 && nextLine === -1) {
lineToPreviewMap[i] = 0
} else if (prevLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[nextLine]
} else if (nextLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[prevLine]
} else {
const ratio = (i - prevLine) / (nextLine - prevLine)
lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine])
}
}
return { lineToPreviewMap }
}
+56
View File
@@ -0,0 +1,56 @@
import type { SearchMatch, SearchOptions } from '../types/search'
export function findMatches(
content: string,
searchText: string,
options: SearchOptions
): SearchMatch[] {
if (!searchText) return []
const matches: SearchMatch[] = []
if (options.useRegex) {
try {
const flags = options.caseSensitive ? 'g' : 'gi'
const regex = new RegExp(searchText, flags)
let m: RegExpExecArray | null
while ((m = regex.exec(content)) !== null) {
matches.push({ start: m.index, end: m.index + m[0].length })
if (m[0].length === 0) regex.lastIndex++
}
} catch {
// invalid regex — return empty
}
} else {
const haystack = options.caseSensitive ? content : content.toLowerCase()
const needle = options.caseSensitive ? searchText : searchText.toLowerCase()
let idx = 0
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
matches.push({ start: idx, end: idx + needle.length })
idx += needle.length
}
}
return matches
}
// 构建行首位置缓存(用于 O(log N) 二分查找行号)
export function buildLineStarts(content: string): number[] {
const starts = [0]
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) starts.push(i + 1) // '\n' = 0x0A
}
return starts
}
// 二分查找:返回 pos 所在行号
export function getLineNumber(lineStarts: number[], pos: number): number {
let lo = 0
let hi = lineStarts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (lineStarts[mid] <= pos) lo = mid
else hi = mid - 1
}
return lo
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/variables.css'
import './styles/global.css'
import './styles/markdown-body.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
import type { ViewMode } from '../types/settings'
interface EditorState {
viewMode: ViewMode
darkMode: boolean
splitRatio: number
setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void
setSplitRatio: (ratio: number) => void
toggleDarkMode: () => void
}
export const useEditorStore = create<EditorState>((set) => ({
viewMode: 'split',
darkMode: false,
splitRatio: 50,
setViewMode: (mode) => set({ viewMode: mode }),
setDarkMode: (dark) => set({ darkMode: dark }),
setSplitRatio: (ratio) => set({ splitRatio: ratio }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
}))
+64
View File
@@ -0,0 +1,64 @@
import { create } from 'zustand'
import type { SearchMatch, SearchOptions } from '../types/search'
interface SearchState {
isVisible: boolean
showReplace: boolean
searchText: string
replaceText: string
matches: SearchMatch[]
currentIndex: number
options: SearchOptions
setVisible: (visible: boolean) => void
setShowReplace: (show: boolean) => void
setSearchText: (text: string) => void
setReplaceText: (text: string) => void
setMatches: (matches: SearchMatch[]) => void
setCurrentIndex: (index: number) => void
toggleCaseSensitive: () => void
toggleRegex: () => void
findNext: () => void
findPrev: () => void
close: () => void
}
export const useSearchStore = create<SearchState>((set, get) => ({
isVisible: false,
showReplace: false,
searchText: '',
replaceText: '',
matches: [],
currentIndex: -1,
options: { caseSensitive: false, useRegex: false },
setVisible: (visible) => set({ isVisible: visible }),
setShowReplace: (show) => set({ showReplace: show }),
setSearchText: (text) => set({ searchText: text }),
setReplaceText: (text) => set({ replaceText: text }),
setMatches: (matches) => set({ matches, currentIndex: matches.length > 0 ? 0 : -1 }),
setCurrentIndex: (index) => set({ currentIndex: index }),
toggleCaseSensitive: () =>
set(state => ({ options: { ...state.options, caseSensitive: !state.options.caseSensitive } })),
toggleRegex: () =>
set(state => ({ options: { ...state.options, useRegex: !state.options.useRegex } })),
findNext: () => {
const { matches, currentIndex } = get()
if (matches.length === 0) return
set({ currentIndex: (currentIndex + 1) % matches.length })
},
findPrev: () => {
const { matches, currentIndex } = get()
if (matches.length === 0) return
set({ currentIndex: (currentIndex - 1 + matches.length) % matches.length })
},
close: () =>
set({
isVisible: false,
showReplace: false,
searchText: '',
replaceText: '',
matches: [],
currentIndex: -1
})
}))
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import type { FileNode } from '../types/file'
interface SidebarState {
isVisible: boolean
rootPath: string | null
tree: FileNode[]
expandedDirs: Set<string>
sidebarWidth: number
setVisible: (visible: boolean) => void
setRootPath: (path: string | null) => void
setTree: (tree: FileNode[]) => void
toggleDir: (path: string) => void
setSidebarWidth: (width: number) => void
}
export const useSidebarStore = create<SidebarState>((set) => ({
isVisible: true,
rootPath: null,
tree: [],
expandedDirs: new Set<string>(),
sidebarWidth: 240,
setVisible: (visible) => set({ isVisible: visible }),
setRootPath: (path) => set({ rootPath: path }),
setTree: (tree) => set({ tree }),
toggleDir: (path) =>
set(state => {
const newSet = new Set(state.expandedDirs)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
return { expandedDirs: newSet }
}),
setSidebarWidth: (width) => set({ sidebarWidth: width })
}))
+127
View File
@@ -0,0 +1,127 @@
import { create } from 'zustand'
import type { Tab } from '../types/tab'
interface TabState {
tabs: Tab[]
activeTabId: string | null
mruStack: string[]
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
getTabIndex: (tabId: string) => number
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
}
let tabIdCounter = 0
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: String(++tabIdCounter),
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
const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack
return {
activeTabId: tabId,
mruStack: newMru
}
})
},
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
},
getTabIndex: (tabId) => {
return get().tabs.findIndex(t => t.id === tabId)
},
updateTabScroll: (tabId, scroll) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, ...scroll } : t
)
}))
}
}))
+792
View File
@@ -0,0 +1,792 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: var(--font-ui);
font-size: 14px;
color: var(--text);
background: var(--bg);
overflow: hidden;
user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Toolbar */
#toolbar {
height: var(--toolbar-height);
background: var(--bg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
flex-shrink: 0;
z-index: 10;
}
.toolbar-left, .toolbar-right {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-ui);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.toolbar-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.toolbar-btn.active {
background: var(--primary-light);
color: var(--primary);
}
.toolbar-btn svg { flex-shrink: 0; }
.toolbar-divider {
width: 1px;
height: 24px;
background: var(--border);
margin: 0 6px;
}
/* Workspace */
#workspace {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
min-height: 0;
}
#main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
min-width: 0;
}
#content-wrapper {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
min-height: 0;
}
/* Editor Panel */
#editor-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--border);
}
#editor-wrapper {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
#line-numbers {
width: 50px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-light);
padding: 12px 0;
text-align: right;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--text-tertiary);
overflow: hidden;
user-select: none;
flex-shrink: 0;
}
#line-numbers .line-num {
padding-right: 12px;
height: 20.8px;
}
#editor {
flex: 1;
width: 100%;
padding: 12px 16px;
border: none;
outline: none;
resize: none;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
tab-size: 4;
overflow-y: auto;
user-select: text;
}
#editor::placeholder { color: var(--text-tertiary); }
/* Resizer */
#resizer {
width: 4px;
background: var(--border);
cursor: col-resize;
transition: background 0.15s ease;
flex-shrink: 0;
}
#resizer:hover, #resizer.active {
background: var(--primary);
}
/* Preview Panel */
#preview-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
background: var(--bg);
}
#preview {
padding: 24px 32px;
max-width: 900px;
margin: 0 auto;
user-select: text;
cursor: text;
}
/* Tab Bar */
#tab-bar {
height: 36px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-end;
padding: 0 4px;
flex-shrink: 0;
overflow-x: auto;
overflow-y: hidden;
}
#tab-bar::-webkit-scrollbar { height: 0; }
#tab-list {
display: flex;
align-items: flex-end;
gap: 1px;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
}
#tab-list::-webkit-scrollbar { height: 0; }
.tab-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
white-space: nowrap;
max-width: 180px;
min-width: 60px;
flex-shrink: 0;
transition: all 0.15s ease;
position: relative;
}
.tab-item:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.tab-item.active {
color: var(--text);
background: var(--bg);
border-bottom-color: var(--primary);
}
.tab-item.modified .tab-name::after {
content: ' •';
color: var(--primary);
}
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
color: var(--text-tertiary);
border-radius: 3px;
cursor: pointer;
flex-shrink: 0;
padding: 0;
opacity: 0;
transition: all 0.1s ease;
}
.tab-item:hover .tab-close,
.tab-item.active .tab-close { opacity: 1; }
.tab-close:hover {
background: var(--border);
color: var(--text);
}
.tab-add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius);
cursor: pointer;
margin-left: 2px;
margin-bottom: 4px;
flex-shrink: 0;
transition: all 0.15s ease;
}
.tab-add-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
/* Modified Banner */
#modified-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: #fff3cd;
border-bottom: 1px solid #ffc107;
font-size: 13px;
color: #856404;
flex-shrink: 0;
}
:root.dark #modified-banner {
background: #3a3000;
border-bottom-color: #665500;
color: #ffd54f;
}
.banner-btn {
padding: 2px 10px;
border: 1px solid #ffc107;
border-radius: 4px;
background: transparent;
color: #856404;
font-size: 12px;
cursor: pointer;
font-family: var(--font-ui);
}
:root.dark .banner-btn {
border-color: #665500;
color: #ffd54f;
}
.banner-btn:hover {
background: #ffc107;
color: #000;
}
/* Status Bar */
#statusbar {
height: var(--statusbar-height);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 8px;
}
.status-divider { color: var(--border); }
/* Drop Overlay */
#drop-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(26, 115, 232, 0.1);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
border: 3px dashed var(--primary);
margin: 8px;
border-radius: 12px;
}
.drop-content {
text-align: center;
color: var(--primary);
}
.drop-content svg {
margin-bottom: 12px;
opacity: 0.7;
}
.drop-content p {
font-size: 18px;
font-weight: 500;
}
/* Welcome Screen */
#welcome-screen {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 5;
}
.welcome-content {
text-align: center;
max-width: 480px;
padding: 40px;
animation: fadeIn 0.3s ease;
}
.welcome-icon { margin-bottom: 24px; }
.welcome-content h1 {
font-size: 28px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
}
.welcome-content > p {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 32px;
}
.welcome-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 32px;
}
.welcome-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-family: var(--font-ui);
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.welcome-btn.primary {
background: var(--primary);
color: white;
}
.welcome-btn.primary:hover {
background: var(--primary-dark);
box-shadow: var(--shadow-lg);
}
.welcome-btn.secondary {
background: var(--bg-secondary);
color: var(--text);
border: 1px solid var(--border);
}
.welcome-btn.secondary:hover { background: var(--bg-tertiary); }
.welcome-tips {
text-align: left;
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 16px 20px;
border: 1px solid var(--border-light);
}
.welcome-tips p {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.welcome-tips p:last-child { margin-bottom: 0; }
/* Sidebar */
#sidebar {
width: var(--sidebar-width);
min-width: 180px;
max-width: 500px;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
user-select: none;
position: relative;
}
#sidebar.collapsed { display: none; }
#sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--sidebar-border);
flex-shrink: 0;
}
#sidebar-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sidebar-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.sidebar-header-btn:hover {
background: var(--sidebar-hover);
color: var(--text);
}
#sidebar-tree {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
.tree-item {
display: flex;
align-items: center;
padding: 3px 8px 3px 0;
cursor: pointer;
font-size: 13px;
color: var(--text);
white-space: nowrap;
transition: background 0.1s ease;
border-radius: 0;
}
.tree-item:hover { background: var(--sidebar-hover); }
.tree-item.active {
background: var(--sidebar-active);
color: var(--primary);
font-weight: 500;
}
.tree-indent { flex-shrink: 0; }
.tree-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-tertiary);
transition: transform 0.15s ease;
}
.tree-arrow.expanded { transform: rotate(90deg); }
.tree-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
margin-right: 4px;
}
.tree-icon svg { width: 14px; height: 14px; }
.tree-name {
overflow: hidden;
text-overflow: ellipsis;
}
/* Independent Files Section */
.independent-files-section {
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
padding-bottom: 4px;
}
.sidebar-section-header, .independent-files-header {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 12px 4px 12px;
}
.independent-file-item .independent-modified-dot {
color: var(--primary);
font-size: 14px;
margin-left: 4px;
}
.sidebar-resize-handle {
position: absolute;
top: 0;
right: -2px;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 5;
}
.sidebar-resize-handle:hover,
.sidebar-resize-handle:active {
background: var(--primary);
}
/* Search & Replace Bar */
#btn-toggle-replace {
font-size: 10px;
transition: transform 0.15s ease;
}
#btn-toggle-replace.expanded { transform: rotate(90deg); }
#search-bar {
background: var(--search-bg);
border-bottom: 1px solid var(--search-border);
padding: 6px 10px;
flex-shrink: 0;
}
.search-row, #replace-row {
display: flex;
align-items: center;
gap: 4px;
}
#replace-row { margin-top: 4px; }
#search-input, #replace-input {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 13px;
font-family: var(--font-ui);
outline: none;
transition: border-color 0.15s ease;
}
#search-input:focus, #replace-input:focus { border-color: var(--primary); }
#search-count {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
min-width: 40px;
text-align: center;
}
.search-opt-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.15s ease;
}
.search-opt-btn:hover { background: var(--bg-tertiary); }
.search-opt-btn.active {
background: var(--primary-light);
color: var(--primary);
border-color: var(--primary);
}
.search-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.search-nav-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.replace-btn {
height: 26px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.replace-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
/* View Modes */
#app.mode-preview #editor-panel,
#app.mode-preview #resizer { display: none; }
/* Toast */
#toast-notification {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--text);
color: var(--bg);
padding: 8px 20px;
border-radius: 6px;
font-size: 13px;
font-family: var(--font-ui);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 9999;
max-width: 480px;
text-align: center;
}
#toast-notification.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
+138
View File
@@ -0,0 +1,138 @@
/* Markdown Body Styles */
.markdown-body {
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.7;
color: var(--text);
word-wrap: break-word;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--text);
}
.markdown-body h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border);
}
.markdown-body h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-light);
}
.markdown-body h3 { font-size: 1.25em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5 { font-size: 0.875em; }
.markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); }
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body a {
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.markdown-body a:hover { text-decoration: underline; }
.markdown-body strong { font-weight: 600; }
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: 8px 0;
}
.markdown-body hr {
height: 2px;
background: var(--border);
border: none;
margin: 24px 0;
border-radius: 1px;
}
.markdown-body blockquote {
margin: 0 0 16px 0;
padding: 4px 16px;
border-left: 4px solid var(--primary);
color: var(--text-secondary);
background: var(--bg-secondary);
border-radius: 0 var(--radius) var(--radius) 0;
}
.markdown-body blockquote p:last-child { margin-bottom: 0; }
.markdown-body ul, .markdown-body ol {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
.markdown-body li { margin-top: 4px; }
.markdown-body li + li { margin-top: 4px; }
.markdown-body code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--code-bg);
padding: 2px 6px;
border-radius: 4px;
color: #e83e8c;
}
:root.dark .markdown-body code { color: #f48fb1; }
.markdown-body pre {
margin-top: 0;
margin-bottom: 16px;
padding: 16px;
background: var(--code-bg);
border-radius: var(--radius);
overflow-x: auto;
border: 1px solid var(--border-light);
}
.markdown-body pre code {
padding: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.5;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
overflow-x: auto;
display: block;
}
.markdown-body table th, .markdown-body table td {
padding: 8px 16px;
border: 1px solid var(--border);
text-align: left;
}
.markdown-body table th {
font-weight: 600;
background: var(--bg-secondary);
}
.markdown-body table tr:nth-child(even) { background: var(--bg-secondary); }
.markdown-body input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--primary);
}
+45
View File
@@ -0,0 +1,45 @@
:root {
--primary: #1a73e8;
--primary-light: #e8f0fe;
--primary-dark: #1557b0;
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #f1f3f4;
--text: #333333;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--border: #e1e4e8;
--border-light: #f0f0f0;
--code-bg: #f6f8fa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1);
--radius: 6px;
--toolbar-height: 44px;
--statusbar-height: 28px;
--sidebar-width: 240px;
--sidebar-bg: var(--bg-secondary);
--sidebar-border: var(--border);
--sidebar-hover: var(--bg-tertiary);
--sidebar-active: var(--primary-light);
--search-bg: var(--bg-secondary);
--search-border: var(--border);
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
}
:root.dark {
--primary: #8ab4f8;
--primary-light: #1a3a5c;
--primary-dark: #aecbfa;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-tertiary: #2d2d2d;
--text: #d4d4d4;
--text-secondary: #9e9e9e;
--text-tertiary: #6e6e6e;
--border: #3e3e3e;
--border-light: #333333;
--code-bg: #2d2d2d;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
}
+13
View File
@@ -0,0 +1,13 @@
export interface FileNode {
name: string
path: string
type: 'file' | 'dir'
children?: FileNode[]
}
export interface DirTreeResult {
success: boolean
tree?: FileNode[]
rootPath?: string
error?: string
}
+5
View File
@@ -0,0 +1,5 @@
export type { Tab } from './tab'
export type { FileNode, DirTreeResult } from './file'
export type { ViewMode, Settings } from './settings'
export type { SearchMatch, SearchOptions } from './search'
export type { ElectronAPI, IpcInvokeMap } from './ipc'
+59
View File
@@ -0,0 +1,59 @@
import type {
OpenFileResponse,
ReadFileResult,
SaveFilePayload,
SaveFileResult,
SaveAsPayload,
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult
} from '../../shared/types'
export interface IpcInvokeMap {
'dialog:openFile': [void, OpenFileResponse]
'file:read': [string, ReadFileResult]
'file:save': [SaveFilePayload, SaveFileResult]
'file:saveAs': [SaveAsPayload, SaveFileResult]
'file:getCurrentPath': [void, string | null]
'file:stats': [string, FileStatsResult]
'file:reload': [void, ReloadFileResult]
'tab:switched': [string | null, void]
'window:forceClose': [void, void]
'window:cancelClose': [void, void]
'dir:readTree': [string, ReadDirTreeResult]
'dir:openDialog': [void, string | null]
'dir:watch': [string, void]
'dir:unwatch': [void, void]
}
export interface ElectronAPI {
openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
getCurrentPath: () => Promise<string | null>
getFileStats: (filePath: string) => Promise<FileStatsResult>
reloadFile: () => Promise<ReloadFileResult>
tabSwitched: (filePath: string | null) => Promise<void>
forceClose: () => Promise<void>
cancelClose: () => Promise<void>
openExternal: (url: string) => void
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => void
onMenuSave: (callback: () => void) => void
onMenuSaveAs: (callback: () => void) => void
onViewModeChange: (callback: (mode: string) => void) => void
onExternalModification: (callback: (filePath: string) => void) => void
onDirChanged: (callback: () => void) => void
onConfirmClose: (callback: () => void) => void
removeAllListeners: (channel: string) => void
}
declare global {
interface Window {
electronAPI?: ElectronAPI
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface SearchMatch {
start: number
end: number
}
export interface SearchOptions {
caseSensitive: boolean
useRegex: boolean
}
+17
View File
@@ -0,0 +1,17 @@
export type ViewMode = 'split' | 'editor' | 'preview'
export interface Settings {
darkMode: boolean
viewMode: ViewMode
splitRatio: number
sidebarCollapsed: boolean
sidebarWidth: number
}
export const DEFAULT_SETTINGS: Settings = {
darkMode: false,
viewMode: 'split',
splitRatio: 50,
sidebarCollapsed: false,
sidebarWidth: 240
}
+11
View File
@@ -0,0 +1,11 @@
export interface Tab {
id: string
filePath: string | null
content: string
isModified: boolean
scrollTop: number
scrollLeft: number
selectionStart: number
selectionEnd: number
previewScrollTop: number
}
+29
View File
@@ -0,0 +1,29 @@
// IPC 通道名常量 — 主进程和渲染进程共享
export const IPC_CHANNELS = {
// 渲染进程 → 主进程 (invoke)
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',
// 主进程 → 渲染进程 (send)
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
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
+65
View File
@@ -0,0 +1,65 @@
// 共享类型定义 — 主进程和渲染进程共用
export interface FileNode {
name: string
path: string
type: 'file' | 'dir'
children?: FileNode[]
}
export interface ReadFileResult {
success: boolean
content?: string
error?: string
}
export interface SaveFilePayload {
filePath: string | null
content: string
}
export interface SaveAsPayload {
content: string
}
export interface SaveFileResult {
success: boolean
filePath?: string
error?: string
canceled?: boolean
}
export interface ReloadFileResult {
success: boolean
content?: string
filePath?: string
error?: string
}
export interface FileStatsResult {
success: boolean
size?: number
mtime?: string
error?: string
}
export interface ReadDirTreeResult {
success: boolean
tree?: FileNode[]
rootPath?: string
error?: string
}
export interface OpenFileResult {
filePath: string
content: string
error?: never
}
export interface OpenFileError {
error: string
filePath?: never
content?: never
}
export type OpenFileResponse = OpenFileResult | OpenFileError | null
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"strict": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/renderer/*"],
"@main/*": ["src/main/*"],
"@shared/*": ["src/shared/*"]
}
},
"references": [
{ "path": "./tsconfig.node.json" }
],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"strict": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"composite": true,
"allowSyntheticDefaultImports": true
},
"include": ["electron.vite.config.ts"]
}