v0.3.10: 全面优化增强 — 17项Bug修复/稳定性/功能改进

P0 致命Bug修复:
- A1: openFolderDialog 类型/运行时崩溃(文件夹打开功能完全失效)
- A2: SourceEditor 受控textarea手动改DOM反模式
- A3: tabStore.updateTabContent 内容相同时误标isModified

死代码清理:
- B1: 删除menu:*/save/as/viewMode 三个永不触发的IPC通道

健壮性修复:
- E1: rehypeFixImages 路径规范化+防越界加固
- E2: getFileName 尾斜杠返回正确文件名
- E3: EditorToolbar 行内代码按钮改用toggleInlineCodeCommand
- E4: ErrorBoundary 内联样式抽为CSS类

功能增强:
- D1: 标签页拖拽排序(tabStore.moveTab + TabBar DnD + CSS)
- D2: Milkdown自动配对括号/引号(ProseMirror插件)
- D3: 状态栏自动保存开关可点击
- D4: 文档大纲活跃标题高亮(useActiveHeading hook)
- D5: 关闭窗口前flush防抖数据(flushSaveToDB)
- D6: 搜索替换支持正则表达式

类型/架构:
- C2: preload类型集中定义(ElectronAPI契约)
- __pycache__ typo修复

文档/版本:
- README/DESIGN同步为Milkdown + v0.3.10
- 项目结构树/技术栈/快捷键表更新

验证: typecheck(仅7预存), lint, test 96/96, vite build
This commit is contained in:
2026-06-23 10:47:45 +08:00
parent 7f070eb11d
commit b65e34e288
27 changed files with 639 additions and 252 deletions
+41 -25
View File
@@ -1,8 +1,8 @@
# MarkLite v0.1.1 — 架构设计文档 # MarkLite v0.3.10 — 架构设计文档
## 1. 项目概述 ## 1. 项目概述
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 CodeMirror 6 编辑器、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。 MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 Milkdown v7 WYSIWYG 编辑器 + 源码编辑模式、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
### 1.1 核心原则 ### 1.1 核心原则
@@ -20,7 +20,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 | | 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
| 前端框架 | React | v18 | 函数组件 + Hooks | | 前端框架 | React | v18 | 函数组件 + Hooks |
| 类型系统 | TypeScript | v5.6 | 全量类型安全 | | 类型系统 | TypeScript | v5.6 | 全量类型安全 |
| 编辑器 | CodeMirror | v6 | 现代化代码编辑器 | | 编辑器 | Milkdown | v7 | WYSIWYG 编辑器 + 源码编辑模式 |
| 状态管理 | Zustand | v5 | 轻量级状态管理 | | 状态管理 | Zustand | v5 | 轻量级状态管理 |
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 | | 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 |
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 | | Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 |
@@ -44,29 +44,36 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
┌───────────────────────▼──────────────────────────────────────┐ ┌───────────────────────▼──────────────────────────────────────┐
│ Preload Script (src/preload/) 1 文件 │ │ Preload Script (src/preload/) 1 文件 │
│ index.ts contextBridge 类型安全暴露 │ │ index.ts contextBridge 类型安全暴露 │
│ electronAPI 22 个方法/事件的类型安全接口 │ │ electronAPI 18 个方法/事件的类型安全接口 │
└───────────────────────┬──────────────────────────────────────┘ └───────────────────────┬──────────────────────────────────────┘
┌───────────────────────▼──────────────────────────────────────┐ ┌───────────────────────▼──────────────────────────────────────┐
│ Renderer Process (src/renderer/) React 18 │ │ Renderer Process (src/renderer/) React 18 │
│ │ │ │
│ components/ (11) Toolbar · TabBar · Editor · Preview │ components/ (23) Toolbar · TabBar · Editor · EditorToolbar
Sidebar · StatusBar · WelcomeScreen SourceEditor · Preview · Sidebar · FileTree
Toast · ModifiedBanner · DropOverlay OutlinePanel · StatusBar · WelcomeScreen
│ Toast · ConfirmDialog · ModifiedBanner │
│ SearchReplace · DropOverlay · ErrorBoundary│
│ AboutDialog · LoadingSpinner · Icons │
│ │ │ │
│ stores/ (3) tabStore · editorStore · sidebarStore │ │ stores/ (3) tabStore · editorStore · sidebarStore │
│ hooks/ (6) useTheme · useSettings · useKeyboard │ hooks/ (19) useTheme · useSettings · useSettingsInit
│ useDragDrop · useFileWatch · useUnsaved useKeyboard · useDragDrop · useFileWatch │
lib/ (3) markdown · fileUtils · constants useAutoSave · useIpcListeners ...
db/ (4) schema · tab · settings · recentFiles lib/ (4) markdown · fileUtils · errorHandler
types/ (6) tab · file · settings · ipc · index constants
│ db/ (4) schema · tabRepository · settingsRepo │
│ recentFilesRepository │
│ types/ (5) tab · file · settings · ipc · index │
│ styles/ (3) variables · global · markdown-body │ │ styles/ (3) variables · global · markdown-body │
└──────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ Shared (src/shared/) 2 文件 │ │ Shared (src/shared/) 3 文件 │
│ ipc-channels.ts IPC 通道名常量 │ │ ipc-channels.ts IPC 通道名常量 │
│ types.ts 共享类型定义 │ │ types.ts 共享类型定义 │
│ constants.ts 共享常量 (版本号、文件大小限制等) │
└──────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
``` ```
@@ -205,21 +212,30 @@ db.version(1).stores({
| `window:confirmClose` | 无 | 请求确认关闭 | | `window:confirmClose` | 无 | 请求确认关闭 |
| `sidebar:dirChanged` | 无 | 目录结构变化 | | `sidebar:dirChanged` | 无 | 目录结构变化 |
## 6. 编辑器架构 — CodeMirror 6 ## 6. 编辑器架构 — Milkdown v7 (WYSIWYG) + SourceEditor (textarea)
### 6.1 功能特性 ### 6.1 双编辑模式
- Markdown 语法高亮 - **编辑模式 (Milkdown)**:基于 ProseMirror 的 WYSIWYG Markdown 编辑器,支持格式化工具栏(粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/分割线)、搜索替换面板(含正则支持)、自动配对括号/引号、undo/redo
- 行号显示 - **源码模式 (SourceEditor)**:原生 textarea 控制 Markdown 原文,支持 Tab 缩进、Ctrl+B/I 快捷键
- 代码折叠 (foldGutter)
- 括号匹配 (bracketMatching)
- 搜索替换 (search) — 中文本地化
- 历史记录 (history) — 支持 undo/redo
- Tab 缩进 (indentWithTab)
- 自动换行 (lineWrapping)
- 暗色主题 (oneDark)
### 6.2 滚动与选区持久化 两种模式共享同一 tabStore 数据源,可随时切换。
### 6.2 插件体系
| 插件 | 说明 |
|------|------|
| commonmark | 基础 Markdown 语法 |
| gfm | GitHub Flavored Markdown(表格/任务列表/删除线等) |
| history | undo/redo |
| listener | 内容变更监听 |
| indent | Tab 缩进 |
| trailing | 尾随换行 |
| clipboard | 剪贴板增强 |
| searchPlugin (自研) | 搜索高亮装饰 |
| autoPairPlugin (自研) | 自动配对括号/引号 |
### 6.3 滚动与选区持久化
切换标签时自动保存/恢复: 切换标签时自动保存/恢复:
- 滚动位置 (`scrollTop`) - 滚动位置 (`scrollTop`)
+25 -10
View File
@@ -14,7 +14,7 @@
<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/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/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/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.6-orange?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/Version-v0.3.10-orange?style=flat-square" alt="Version">
</p> </p>
<p align="center"> <p align="center">
@@ -30,7 +30,7 @@
|------|------| |------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
| ✏️ **实时编辑** | 左侧编辑器(CodeMirror 6),支持 Tab 缩进、行号显示、折叠、括号匹配 | | ✏️ **双编辑模式** | Milkdown WYSIWYG 编辑器(格式化工具栏/搜索替换/正则/自动配对)+ 源码模式(textarea)|
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | | 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 | | 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
@@ -131,6 +131,7 @@ npm run test:coverage
| `Ctrl + Shift + S` | 另存为 | | `Ctrl + Shift + S` | 另存为 |
| `Ctrl + 1` | 编辑模式 | | `Ctrl + 1` | 编辑模式 |
| `Ctrl + 2` | 预览模式 | | `Ctrl + 2` | 预览模式 |
| `Ctrl + 3` | 源码模式 |
| `Ctrl + F` | 搜索 | | `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 | | `Ctrl + H` | 搜索并替换 |
| `Ctrl + B` | 粗体 | | `Ctrl + B` | 粗体 |
@@ -145,7 +146,7 @@ npm run test:coverage
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | | 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | | 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | | 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
| 编辑器 | [CodeMirror](https://codemirror.net/) v6 | 现代化代码编辑器 | | 编辑器 | [Milkdown](https://milkdown.dev/) v7 | WYSIWYG 编辑器 + 源码模式 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | | 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | | 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | | Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
@@ -182,17 +183,25 @@ MarkLite/
│ │ ├── main.tsx # React 入口 │ │ ├── main.tsx # React 入口
│ │ ├── App.tsx # 根组件:布局编排、全局事件 │ │ ├── App.tsx # 根组件:布局编排、全局事件
│ │ │ │ │ │
│ │ ├── components/ # UI 组件 │ │ ├── components/ # UI 组件 (23个)
│ │ │ ├── Toolbar/ # 工具栏 │ │ │ ├── Toolbar/ # 工具栏
│ │ │ ├── TabBar/ # 标签页栏 │ │ │ ├── TabBar/ # 标签页栏 (含拖拽排序)
│ │ │ ├── Editor/ # CodeMirror 6 编辑器 │ │ │ ├── Editor/ # Milkdown 编辑器 + 工具栏
│ │ │ ├── Preview/ # Markdown 预览面板 │ │ │ ├── Preview/ # Markdown 预览面板
│ │ │ ├── SourceEditor/ # 源码编辑模式 (textarea)
│ │ │ ├── Sidebar/ # 侧边栏文件树 │ │ │ ├── Sidebar/ # 侧边栏文件树
│ │ │ ├── StatusBar/ # 状态栏 │ │ │ ├── FileTree/ # 递归文件树
│ │ │ ├── OutlinePanel/ # 文档大纲 (活跃标题高亮)
│ │ │ ├── StatusBar/ # 状态栏 (自动保存开关)
│ │ │ ├── SearchReplace/ # 搜索替换面板 (支持正则)
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕 │ │ │ ├── WelcomeScreen/ # 欢迎屏幕
│ │ │ ├── Toast/ # Toast 通知 │ │ │ ├── Toast/ # Toast 通知
│ │ │ ├── ConfirmDialog/ # 确认对话框
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示 │ │ │ ├── ModifiedBanner/ # 文件外部修改提示
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层 │ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
│ │ │ ├── ErrorBoundary/ # 错误边界
│ │ │ ├── AboutDialog/ # 关于对话框
│ │ │ ├── LoadingSpinner/ # 加载指示器
│ │ │ └── Icons.tsx # SVG 图标库 │ │ │ └── Icons.tsx # SVG 图标库
│ │ │ │ │ │
│ │ ├── stores/ # Zustand 状态管理 │ │ ├── stores/ # Zustand 状态管理
@@ -200,17 +209,23 @@ MarkLite/
│ │ │ ├── editorStore.ts # 编辑器状态 │ │ │ ├── editorStore.ts # 编辑器状态
│ │ │ └── sidebarStore.ts # 侧边栏状态 │ │ │ └── sidebarStore.ts # 侧边栏状态
│ │ │ │ │ │
│ │ ├── hooks/ # 自定义 Hooks │ │ ├── hooks/ # 自定义 Hooks (19个)
│ │ │ ├── useTheme.ts # 暗色/亮色主题 │ │ │ ├── useTheme.ts # 暗色/亮色主题
│ │ │ ├── useSettings.ts # 用户设置 │ │ │ ├── useSettings.ts # 视图模式
│ │ │ ├── useSettingsInit.ts # 设置初始加载
│ │ │ ├── useKeyboard.ts # 全局快捷键 │ │ │ ├── useKeyboard.ts # 全局快捷键
│ │ │ ├── useDragDrop.ts # 拖拽打开 │ │ │ ├── useDragDrop.ts # 拖拽打开
│ │ │ ├── useFileWatch.ts # 外部修改监听 │ │ │ ├── useFileWatch.ts # 外部修改监听
│ │ │ ── useUnsavedWarning.ts # 未保存提醒 │ │ │ ── useUnsavedWarning.ts # 未保存提醒
│ │ │ ├── useAutoSave.ts # 自动保存
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪
│ │ │ └── ... # 文件操作、IPC监听等
│ │ │ │ │ │
│ │ ├── lib/ # 工具库 │ │ ├── lib/ # 工具库
│ │ │ ├── markdown.ts # Markdown 渲染管线 │ │ │ ├── markdown.ts # Markdown 渲染管线
│ │ │ ├── fileUtils.ts # 文件工具函数 │ │ │ ├── fileUtils.ts # 文件工具函数
│ │ │ ├── errorHandler.ts # 错误处理
│ │ │ └── constants.ts # 常量定义 │ │ │ └── constants.ts # 常量定义
│ │ │ │ │ │
│ │ ├── db/ # IndexedDB 持久化层 │ │ ├── db/ # IndexedDB 持久化层
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.3.8", "version": "0.3.10",
"description": "Lightweight Markdown Editor for Windows", "description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"scripts": { "scripts": {
+12 -23
View File
@@ -1,12 +1,14 @@
import { contextBridge, ipcRenderer, shell } from 'electron' import { contextBridge, ipcRenderer, shell } from 'electron'
import { IPC_CHANNELS } from '../shared/ipc-channels' import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { ElectronAPI } from '../renderer/types/ipc'
contextBridge.exposeInMainWorld('electronAPI', { // C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
const api: ElectronAPI = {
// File operations // File operations
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE), openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath), readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data: { content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH), getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath), getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD), reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
@@ -37,39 +39,26 @@ contextBridge.exposeInMainWorld('electronAPI', {
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH), unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// Events from main process — 返回取消订阅函数 // Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => { onFileOpenInTab: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data) const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
}, },
onMenuSave: (callback: () => void) => { onExternalModification: (callback) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE, handler) }
},
onMenuSaveAs: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE_AS, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE_AS, handler) }
},
onViewModeChange: (callback: (mode: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode)
ipcRenderer.on(IPC_CHANNELS.MENU_VIEW_MODE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_VIEW_MODE, handler) }
},
onExternalModification: (callback: (filePath: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
}, },
onDirChanged: (callback: () => void) => { onDirChanged: (callback) => {
const handler = () => callback() const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
}, },
onConfirmClose: (callback: () => void) => { onConfirmClose: (callback) => {
const handler = () => callback() const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
} }
}) }
contextBridge.exposeInMainWorld('electronAPI', api)
+4 -48
View File
@@ -1,55 +1,11 @@
/** /**
* DX-02: Preload 类型安全声明 * DX-02: Preload 类型安全声明
* 为 window.electronAPI 提供完整的 TypeScript 类型定义, * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
* 确保渲染进程访问 API 时有完整的类型提示和编译时检查。 * preload/index.ts 引入该类型做编译期契约检查。
* 本文件仅保留全局 Window 增强声明。
*/ */
import type { import type { ElectronAPI } from '../renderer/types/ipc'
OpenFileResponse,
ReadFileResult,
SaveFilePayload,
SaveAsPayload,
SaveFileResult,
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult,
} from '../shared/types'
interface ElectronAPI {
// File operations
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>
// Tab management
tabSwitched: (filePath: string | null) => Promise<void>
// Window control
forceClose: () => Promise<void>
cancelClose: () => Promise<void>
// Shell
openExternal: (url: string) => void
// File Tree (Sidebar)
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
// Events from main process — 返回取消订阅函数
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
}
declare global { declare global {
interface Window { interface Window {
+5 -4
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { useTabStore } from './stores/tabStore' import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore' import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme' import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings' import { useSettings } from './hooks/useSettings'
@@ -51,7 +52,7 @@ export function App() {
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast) const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast) useDragDrop(showToast)
useFileWatch() useFileWatch()
const { isAutoSaving, autoSaveEnabled } = useAutoSave() const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
// UX-01: 传入 confirm 函数替代原生 confirm() // UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => { const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
@@ -64,9 +65,9 @@ export function App() {
}) })
}, [confirm]) }, [confirm])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose) useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB)
useKeyboard(handleOpenFile, handleSave, handleSaveAs) useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners(handleSave, handleSaveAs) useIpcListeners()
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
@@ -117,7 +118,7 @@ export function App() {
)} )}
</div> </div>
</div> </div>
<StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} /> <StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} onToggleAutoSave={toggleAutoSave} />
<ToastContainer toasts={toasts} onDismiss={dismissToast} /> <ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay /> <DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />} {showAbout && <AboutDialog onClose={handleCloseAbout} />}
@@ -10,6 +10,7 @@ import {
wrapInBulletListCommand, wrapInBulletListCommand,
wrapInOrderedListCommand, wrapInOrderedListCommand,
createCodeBlockCommand, createCodeBlockCommand,
toggleInlineCodeCommand,
insertImageCommand, insertImageCommand,
toggleLinkCommand, toggleLinkCommand,
insertHrCommand insertHrCommand
@@ -63,7 +64,7 @@ export const EditorToolbar = React.memo(function EditorToolbar({ action }: Edito
</button> </button>
<button <button
className="toolbar-btn-sm" className="toolbar-btn-sm"
onClick={() => exec(createCodeBlockCommand)} onClick={() => exec(toggleInlineCodeCommand)}
title="行内代码" title="行内代码"
aria-label="行内代码" aria-label="行内代码"
> >
+63 -2
View File
@@ -18,6 +18,67 @@ import { Plugin, PluginKey } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view' import { Decoration, DecorationSet } from '@milkdown/prose/view'
import type { EditorState } from '@milkdown/prose/state' import type { EditorState } from '@milkdown/prose/state'
// --- Auto-pair plugin (D2) ---
const PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
function createAutoPairPlugin(): Plugin {
return new Plugin({
props: {
handleTextInput(view, from, to, text) {
// 选中文本时用配对符号包裹
if (from !== to && PAIRS[text]) {
const close = PAIRS[text]
const { tr } = view.state
tr.insertText(text + view.state.doc.textBetween(from, to) + close, from, to)
view.dispatch(tr)
return true
}
return false
},
handleKeyDown(view, event) {
// 处理配对符号的自动闭合
const char = event.key
if (!PAIRS[char]) return false
const { state } = view
const { from, to } = state.selection
// Backspace: 删除配对符号(光标在两个配对符号中间时)
if (char === 'Backspace') {
if (from !== to || from < 2) return false
const before = state.doc.textBetween(from - 1, from)
const after = state.doc.textBetween(from, from + 1)
if (PAIRS[before] === after) {
const tr = state.tr.delete(from - 1, from + 1)
view.dispatch(tr)
return true
}
return false
}
if (from !== to) {
// 有选区时:包裹
const close = PAIRS[char]
const tr = state.tr.insertText(char + state.doc.textBetween(from, to) + close, from, to)
view.dispatch(tr)
return true
}
// 无选区:插入配对并光标置中
const close = PAIRS[char]
const tr = state.tr.insertText(char + close, from)
// 光标置于中间
tr.setSelection(
state.selection.constructor.create(tr.doc, from + 1)
)
view.dispatch(tr)
return true
}
}
})
}
// --- Search highlight plugin --- // --- Search highlight plugin ---
export interface SearchMatch { export interface SearchMatch {
@@ -105,8 +166,8 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
ctx.set(rootCtx, containerRef.current!) ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current) ctx.set(defaultValueCtx, initialContentRef.current)
// Inject search highlight plugin into ProseMirror plugin list // Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()]) ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
// Configure listener for content changes // Configure listener for content changes
const lm = ctx.get(listenerCtx) const lm = ctx.get(listenerCtx)
@@ -35,52 +35,20 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.fallback return this.props.fallback
} }
const isDev = (typeof import.meta !== 'undefined' && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? false const isDev =
(typeof import.meta !== 'undefined' &&
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
false
return ( return (
<div <div className="error-boundary-root" role="alert">
style={{ <h2 className="error-boundary-title"></h2>
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
textAlign: 'center',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
</h2>
{isDev && ( {isDev && (
<pre <pre className="error-boundary-detail">
style={{
padding: '1rem',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
maxWidth: '600px',
overflow: 'auto',
fontSize: '0.875rem',
color: '#666',
}}
>
{this.state.error?.message} {this.state.error?.message}
</pre> </pre>
)} )}
<button <button className="error-boundary-reset" onClick={this.handleReset}>
onClick={this.handleReset}
style={{
marginTop: '1rem',
padding: '0.5rem 1.5rem',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '1rem',
}}
>
</button> </button>
</div> </div>
@@ -13,13 +13,37 @@ interface SearchReplaceProps {
/** /**
* 在 ProseMirror 文档中查找所有匹配位置 * 在 ProseMirror 文档中查找所有匹配位置
* D6: 支持正则表达式 + 大小写敏感
*/ */
function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean): SearchMatch[] { function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean, useRegex: boolean): SearchMatch[] {
const matches: SearchMatch[] = [] const matches: SearchMatch[] = []
if (!query) return matches if (!query) return matches
const normalizedQuery = caseSensitive ? query : query.toLowerCase() if (useRegex) {
let regex: RegExp
try {
const flags = caseSensitive ? 'g' : 'gi'
regex = new RegExp(query, flags)
} catch {
// 正则语法错误 — 返回空,UI 通过 regexError 状态提示用户
return []
}
doc.descendants((node, pos) => {
if (!node.isText) return true
const text = node.text || ''
regex.lastIndex = 0
let result: RegExpExecArray | null
while ((result = regex.exec(text)) !== null) {
matches.push({ from: pos + result.index, to: pos + result.index + result[0].length })
if (result[0].length === 0) regex.lastIndex++ // 避免空匹配死循环
}
return true
})
return matches
}
// 普通字符串匹配
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (!node.isText) return true if (!node.isText) return true
const text = node.text || '' const text = node.text || ''
@@ -82,6 +106,8 @@ export const SearchReplace = memo(function SearchReplace({
const [replacement, setReplacement] = useState('') const [replacement, setReplacement] = useState('')
const [showReplace, setShowReplace] = useState(false) const [showReplace, setShowReplace] = useState(false)
const [caseSensitive, setCaseSensitive] = useState(false) const [caseSensitive, setCaseSensitive] = useState(false)
const [useRegex, setUseRegex] = useState(false)
const [regexError, setRegexError] = useState(false)
const [matchCount, setMatchCount] = useState(0) const [matchCount, setMatchCount] = useState(0)
const [currentMatch, setCurrentMatch] = useState(-1) const [currentMatch, setCurrentMatch] = useState(-1)
@@ -92,17 +118,30 @@ export const SearchReplace = memo(function SearchReplace({
const matchesRef = useRef<SearchMatch[]>([]) const matchesRef = useRef<SearchMatch[]>([])
const currentIndexRef = useRef(-1) const currentIndexRef = useRef(-1)
const caseSensitiveRef = useRef(false) const caseSensitiveRef = useRef(false)
const useRegexRef = useRef(false)
const queryRef = useRef('') const queryRef = useRef('')
// Sync refs // Sync refs
caseSensitiveRef.current = caseSensitive caseSensitiveRef.current = caseSensitive
useRegexRef.current = useRegex
queryRef.current = query queryRef.current = query
// 执行搜索 // 执行搜索
const doSearch = useCallback((searchQuery: string, cs: boolean) => { const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => {
const view = getView() const view = getView()
if (!view) return if (!view) return
// D6: 检查正则语法
setRegexError(false)
if (rx && searchQuery.trim()) {
try {
new RegExp(searchQuery, 'g')
} catch {
setRegexError(true)
return
}
}
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
clearSearchDecorations(view) clearSearchDecorations(view)
matchesRef.current = [] matchesRef.current = []
@@ -112,7 +151,7 @@ export const SearchReplace = memo(function SearchReplace({
return return
} }
const matches = findMatches(view.state.doc, searchQuery, cs) const matches = findMatches(view.state.doc, searchQuery, cs, rx)
matchesRef.current = matches matchesRef.current = matches
const newIdx = matches.length > 0 ? 0 : -1 const newIdx = matches.length > 0 ? 0 : -1
currentIndexRef.current = newIdx currentIndexRef.current = newIdx
@@ -133,14 +172,21 @@ export const SearchReplace = memo(function SearchReplace({
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value const value = e.target.value
setQuery(value) setQuery(value)
doSearch(value, caseSensitiveRef.current) doSearch(value, caseSensitiveRef.current, useRegexRef.current)
}, [doSearch]) }, [doSearch])
// 大小写切换 // 大小写切换
const toggleCaseSensitive = useCallback(() => { const toggleCaseSensitive = useCallback(() => {
const newCS = !caseSensitiveRef.current const newCS = !caseSensitiveRef.current
setCaseSensitive(newCS) setCaseSensitive(newCS)
doSearch(queryRef.current, newCS) doSearch(queryRef.current, newCS, useRegexRef.current)
}, [doSearch])
// 正则切换
const toggleRegex = useCallback(() => {
const newRx = !useRegexRef.current
setUseRegex(newRx)
doSearch(queryRef.current, caseSensitiveRef.current, newRx)
}, [doSearch]) }, [doSearch])
// 导航到下一个/上一个匹配 // 导航到下一个/上一个匹配
@@ -196,7 +242,7 @@ export const SearchReplace = memo(function SearchReplace({
// Re-search after replacement (document changed) // Re-search after replacement (document changed)
// Use requestAnimationFrame to let ProseMirror process the transaction // Use requestAnimationFrame to let ProseMirror process the transaction
requestAnimationFrame(() => { requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current) doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
}) })
}, [getView, replacement, doSearch]) }, [getView, replacement, doSearch])
@@ -206,7 +252,8 @@ export const SearchReplace = memo(function SearchReplace({
if (!view) return if (!view) return
// 重新搜索以获取匹配的最新位置 // 重新搜索以获取匹配的最新位置
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current) const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
if (freshMatches.length === 0) return
if (freshMatches.length === 0) return if (freshMatches.length === 0) return
// 从后往前替换以保持位置正确 // 从后往前替换以保持位置正确
@@ -221,7 +268,7 @@ export const SearchReplace = memo(function SearchReplace({
// 替换后重新搜索 // 替换后重新搜索
requestAnimationFrame(() => { requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current) doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
}) })
}, [getView, replacement, doSearch]) }, [getView, replacement, doSearch])
@@ -300,7 +347,7 @@ export const SearchReplace = memo(function SearchReplace({
aria-label="搜索文本" aria-label="搜索文本"
/> />
<span className="search-count" aria-live="polite"> <span className="search-count" aria-live="polite">
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''} {regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
</span> </span>
</div> </div>
<button <button
@@ -312,6 +359,15 @@ export const SearchReplace = memo(function SearchReplace({
> >
Aa Aa
</button> </button>
<button
className={`search-btn ${useRegex ? 'active' : ''}`}
onClick={toggleRegex}
title="使用正则表达式 (点击切换)"
aria-label="正则表达式"
aria-pressed={useRegex}
>
.*
</button>
<button <button
className="search-btn" className="search-btn"
onClick={() => navigateMatch(-1)} onClick={() => navigateMatch(-1)}
+20 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore' import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
@@ -8,6 +8,7 @@ import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize' import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations' import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { useActiveHeading } from '../../hooks/useActiveHeading'
import { OutlinePanel, parseHeadings } from '../OutlinePanel' import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel' import type { Heading } from '../OutlinePanel'
import { getEditorView, useEditorStore } from '../../stores/editorStore' import { getEditorView, useEditorStore } from '../../stores/editorStore'
@@ -33,6 +34,7 @@ export const Sidebar = React.memo(function Sidebar() {
const { sidebarRef, startResize } = useSidebarResize() const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations() const { handleOpenFolder } = useFolderOperations()
const setLoading = useEditorStore(s => s.setLoading) const setLoading = useEditorStore(s => s.setLoading)
const viewMode = useEditorStore(s => s.viewMode)
useAutoExpandDir(activeFilePath) useAutoExpandDir(activeFilePath)
// Parse headings from active tab content // Parse headings from active tab content
@@ -41,6 +43,22 @@ export const Sidebar = React.memo(function Sidebar() {
return parseHeadings(activeTab.content) return parseHeadings(activeTab.content)
}, [activeTab?.content]) }, [activeTab?.content])
// D4: 追踪预览面板中的活跃标题
const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => {
// 仅在预览模式时获取 preview DOM 节点
if (viewMode === 'preview') {
previewRef.current = document.getElementById('preview')
} else {
previewRef.current = null
}
}, [viewMode])
const activeHeadingIndex = useActiveHeading(
viewMode === 'preview' ? previewRef : { current: null },
headings
)
// Navigate to heading in ProseMirror document tree // Navigate to heading in ProseMirror document tree
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => { const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
const view = getEditorView() const view = getEditorView()
@@ -149,7 +167,7 @@ export const Sidebar = React.memo(function Sidebar() {
<OutlinePanel <OutlinePanel
headings={headings} headings={headings}
onNavigate={handleHeadingNavigate} onNavigate={handleHeadingNavigate}
activeHeadingIndex={null} activeHeadingIndex={activeHeadingIndex}
/> />
</div> </div>
<div <div
@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react' import React, { useCallback, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
interface SourceEditorProps { interface SourceEditorProps {
@@ -8,6 +8,9 @@ interface SourceEditorProps {
/** /**
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。 * 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。 * 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
*
* A2: 完全受控 — 所有变更通过 updateTabContent 驱动,
* 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。
*/ */
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) { export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
const activeTab = useTabStore(s => s.getActiveTab()) const activeTab = useTabStore(s => s.getActiveTab())
@@ -16,6 +19,22 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
const setModified = useTabStore(s => s.setModified) const setModified = useTabStore(s => s.setModified)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
// A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复
const pendingSelectionRef = useRef<number | null>(null)
useEffect(() => {
if (pendingSelectionRef.current === null) return
const textarea = textareaRef.current
if (!textarea) return
const pos = pendingSelectionRef.current
// microtask:在 React 完成 DOM 更新后恢复光标
requestAnimationFrame(() => {
textarea.selectionStart = pos
textarea.selectionEnd = pos
})
pendingSelectionRef.current = null
}, [activeTab?.content])
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!activeTabId) return if (!activeTabId) return
updateTabContent(activeTabId, e.target.value) updateTabContent(activeTabId, e.target.value)
@@ -24,56 +43,49 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isCtrl = e.ctrlKey || e.metaKey const isCtrl = e.ctrlKey || e.metaKey
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
// Tab 插入两个空格而非跳转焦点 // Tab 插入两个空格而非跳转焦点
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault() e.preventDefault()
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const value = textarea.value const value = textarea.value
const newValue = value.substring(0, start) + ' ' + value.substring(end) const newValue = value.substring(0, start) + ' ' + value.substring(end)
textarea.value = newValue const newPos = start + 2
textarea.selectionStart = textarea.selectionEnd = start + 2
if (activeTabId) {
updateTabContent(activeTabId, newValue) updateTabContent(activeTabId, newValue)
setModified(activeTabId, true) setModified(activeTabId, true)
} pendingSelectionRef.current = newPos
return
} }
// Ctrl+B: 粗体 // Ctrl+B: 粗体
if (isCtrl && e.key === 'b') { if (isCtrl && e.key === 'b') {
e.preventDefault() e.preventDefault()
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const value = textarea.value const value = textarea.value
const selected = value.substring(start, end) const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end) const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = start + 2
textarea.selectionEnd = end + 2
updateTabContent(activeTabId, newValue) updateTabContent(activeTabId, newValue)
setModified(activeTabId, true) setModified(activeTabId, true)
pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2
return
} }
// Ctrl+I: 斜体 // Ctrl+I: 斜体
if (isCtrl && e.key === 'i') { if (isCtrl && e.key === 'i') {
e.preventDefault() e.preventDefault()
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const value = textarea.value const value = textarea.value
const selected = value.substring(start, end) const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end) const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = start + 1
textarea.selectionEnd = end + 1
updateTabContent(activeTabId, newValue) updateTabContent(activeTabId, newValue)
setModified(activeTabId, true) setModified(activeTabId, true)
pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1
return
} }
}, [activeTabId, updateTabContent, setModified]) }, [activeTabId, updateTabContent, setModified])
@@ -8,11 +8,13 @@ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
interface StatusBarProps { interface StatusBarProps {
isAutoSaving?: boolean isAutoSaving?: boolean
autoSaveEnabled?: boolean autoSaveEnabled?: boolean
onToggleAutoSave?: () => void
} }
export const StatusBar = React.memo(function StatusBar({ export const StatusBar = React.memo(function StatusBar({
isAutoSaving = false, isAutoSaving = false,
autoSaveEnabled = true autoSaveEnabled = true,
onToggleAutoSave
}: StatusBarProps) { }: StatusBarProps) {
const activeTab = useTabStore(s => s.getActiveTab()) const activeTab = useTabStore(s => s.getActiveTab())
const loadingStates = useEditorStore(s => s.loadingStates) const loadingStates = useEditorStore(s => s.loadingStates)
@@ -58,15 +60,16 @@ export const StatusBar = React.memo(function StatusBar({
<span className="status-divider">|</span> <span className="status-divider">|</span>
</> </>
)} )}
{activeTab?.filePath && autoSaveEnabled && ( {activeTab?.filePath && onToggleAutoSave !== undefined && (
<> <>
<span <button
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`} className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : '自动保存已开启'} title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : '自动保存'} aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
onClick={onToggleAutoSave}
> >
{isAutoSaving ? '保存中...' : '自动'} {isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
</span> </button>
<span className="status-divider">|</span> <span className="status-divider">|</span>
</> </>
)} )}
+52 -2
View File
@@ -21,9 +21,12 @@ export const TabBar = React.memo(function TabBar() {
const closeOtherTabs = useTabStore(s => s.closeOtherTabs) const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
const closeAllTabs = useTabStore(s => s.closeAllTabs) const closeAllTabs = useTabStore(s => s.closeAllTabs)
const closeTabsToRight = useTabStore(s => s.closeTabsToRight) const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const moveTab = useTabStore(s => s.moveTab)
const tabListRef = useRef<HTMLDivElement>(null) const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' }) const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragTabIdRef = useRef<string | null>(null)
const { confirm, confirmDialogProps } = useConfirm() const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签 // 滚动到活动标签
@@ -173,6 +176,46 @@ export const TabBar = React.memo(function TabBar() {
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm]) }, [tabs, menu.tabId, closeTabsToRight, confirm])
// D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
dragTabIdRef.current = tabId
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', tabId)
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
requestAnimationFrame(() => {
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
el?.classList.add('dragging')
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}, [])
const handleDragLeave = useCallback(() => {
setDragOverIndex(null)
}, [])
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
e.preventDefault()
setDragOverIndex(null)
const fromId = dragTabIdRef.current
if (fromId) {
moveTab(fromId, toIndex)
}
dragTabIdRef.current = null
// 清理 dragging 类
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
}, [moveTab])
const handleDragEnd = useCallback(() => {
setDragOverIndex(null)
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
dragTabIdRef.current = null
}, [])
const hasRightTabs = menu.visible && (() => { const hasRightTabs = menu.visible && (() => {
const index = tabs.findIndex(t => t.id === menu.tabId) const index = tabs.findIndex(t => t.id === menu.tabId)
return index < tabs.length - 1 return index < tabs.length - 1
@@ -184,15 +227,22 @@ export const TabBar = React.memo(function TabBar() {
<> <>
<div id="tab-bar"> <div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页"> <div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
{tabs.map(tab => ( {tabs.map((tab, index) => (
<div <div
key={tab.id} key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`} className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
role="tab" role="tab"
aria-selected={tab.id === activeTabId} aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1} tabIndex={tab.id === activeTabId ? 0 : -1}
data-tab-id={tab.id}
draggable
onClick={() => switchToTab(tab.id)} onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)} onContextMenu={(e) => handleContextMenu(e, tab.id)}
onDragStart={(e) => handleDragStart(e, tab.id)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
> >
<span className="tab-name"> <span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'} {tab.filePath ? getFileName(tab.filePath) : '未命名'}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState, useCallback } from 'react'
/**
* D4: 基于视口的活跃标题追踪 hook
* 在 preview 模式下监听目标容器的滚动事件,
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
*
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
*
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
* @param headings - 解析出的标题列表
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
*/
export function useActiveHeading(
containerRef: React.RefObject<HTMLElement | null>,
headings: { level: number; text: string }[]
): number | null {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const handleScroll = useCallback(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 收集容器内所有 h1-h6 元素的 offsetTop
const headingElements = Array.from(
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
) as HTMLElement[]
if (headingElements.length === 0) {
setActiveIndex(null)
return
}
const scrollTop = container.scrollTop
const containerHeight = container.clientHeight
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
let bestIndex: number | null = null
for (let i = 0; i < headingElements.length; i++) {
const el = headingElements[i]
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window
const top = el.offsetTop - (container.offsetTop || 0)
if (top <= threshold) {
// 找到 headings 中匹配的索引
const text = el.textContent?.trim() ?? ''
const matchIdx = headings.findIndex(
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
)
if (matchIdx >= 0) bestIndex = matchIdx
}
}
setActiveIndex(bestIndex)
}, [containerRef, headings])
useEffect(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 监听滚动事件
container.addEventListener('scroll', handleScroll, { passive: true })
// 初始计算
handleScroll()
const observer = observerRef.current
return () => {
container.removeEventListener('scroll', handleScroll)
observer?.disconnect()
}
}, [containerRef, headings, handleScroll])
return activeIndex
}
+2 -5
View File
@@ -15,10 +15,8 @@ export function useFolderOperations() {
const handleOpenFolder = useCallback(async () => { const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return if (!window.electronAPI) return
const result = await window.electronAPI.openFolderDialog() const dirPath = await window.electronAPI.openFolderDialog()
if (!result) return if (!dirPath) return
if (!result.canceled && result.filePaths[0]) {
const dirPath = result.filePaths[0]
setRootPath(dirPath) setRootPath(dirPath)
expandDirs([dirPath]) expandDirs([dirPath])
setLoading('dir-load', true) setLoading('dir-load', true)
@@ -31,7 +29,6 @@ export function useFolderOperations() {
} finally { } finally {
setLoading('dir-load', false) setLoading('dir-load', false)
} }
}
}, [setRootPath, setTree, expandDirs, setLoading]) }, [setRootPath, setTree, expandDirs, setLoading])
const refreshTree = useCallback(async () => { const refreshTree = useCallback(async () => {
+4 -11
View File
@@ -1,17 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { recentFilesRepository } from '../db/recentFilesRepository' import { recentFilesRepository } from '../db/recentFilesRepository'
/** /**
* 主进程事件注册 hook * 主进程事件注册 hook
* 处理菜单触发的文件打开、保存、另存为事 * 处理通过命令行或文件关联打开的文
*/ */
export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) { export function useIpcListeners() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
const handleSaveRef = useRef(handleSave)
const handleSaveAsRef = useRef(handleSaveAs)
handleSaveRef.current = handleSave
handleSaveAsRef.current = handleSaveAs
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
@@ -21,9 +17,6 @@ export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void
if (data.filePath) recentFilesRepository.add(data.filePath) if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100) setTimeout(() => useTabStore.getState().saveToDB(), 100)
} }
const unsub1 = api.onFileOpenInTab(onOpen) return api.onFileOpenInTab(onOpen)
const unsub2 = api.onMenuSave(() => handleSaveRef.current())
const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current())
return () => { unsub1(); unsub2(); unsub3() }
}, [createTab]) }, [createTab])
} }
+7 -1
View File
@@ -6,10 +6,14 @@ import { useEffect, useCallback, useRef } from 'react'
*/ */
export function useUnsavedWarning( export function useUnsavedWarning(
hasUnsaved: () => boolean, hasUnsaved: () => boolean,
confirmFn?: (message: string) => Promise<boolean> confirmFn?: (message: string) => Promise<boolean>,
// D5: 关闭前回调(flush 待保存数据)
onBeforeForceClose?: () => Promise<void>
) { ) {
const confirmFnRef = useRef(confirmFn) const confirmFnRef = useRef(confirmFn)
confirmFnRef.current = confirmFn confirmFnRef.current = confirmFn
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
onBeforeForceCloseRef.current = onBeforeForceClose
const doConfirm = useCallback(async (message: string): Promise<boolean> => { const doConfirm = useCallback(async (message: string): Promise<boolean> => {
if (confirmFnRef.current) { if (confirmFnRef.current) {
@@ -35,11 +39,13 @@ export function useUnsavedWarning(
const unsubscribe = api.onConfirmClose(async () => { const unsubscribe = api.onConfirmClose(async () => {
if (!hasUnsaved()) { if (!hasUnsaved()) {
await onBeforeForceCloseRef.current?.()
api.forceClose() api.forceClose()
return return
} }
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?') const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) { if (shouldClose) {
await onBeforeForceCloseRef.current?.()
api.forceClose() api.forceClose()
} else { } else {
api.cancelClose() api.cancelClose()
+6 -2
View File
@@ -16,8 +16,12 @@ describe('fileUtils', () => {
}) })
it('should handle path with trailing slash', () => { it('should handle path with trailing slash', () => {
// getFileName uses pop() || filePath, empty string is falsy so returns filePath expect(getFileName('/path/to/dir/')).toBe('dir')
expect(getFileName('/path/to/dir/')).toBe('/path/to/dir/') })
it('should handle path with trailing backslash', () => {
expect(getFileName('C:\\Users\\test\\')).toBe('test')
}) })
it('should handle mixed path separators', () => { it('should handle mixed path separators', () => {
+3 -1
View File
@@ -1,7 +1,9 @@
import { ALLOWED_EXTENSIONS } from './constants' import { ALLOWED_EXTENSIONS } from './constants'
export function getFileName(filePath: string): string { export function getFileName(filePath: string): string {
return filePath.split(/[/\\]/).pop() || filePath // E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
const clean = filePath.replace(/[/\\]+$/, '')
return clean.split(/[/\\]/).pop() || clean || filePath
} }
export function isAllowedFile(filePath: string): boolean { export function isAllowedFile(filePath: string): boolean {
+11 -13
View File
@@ -9,9 +9,13 @@ import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast' import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块) // 简单的路径解析(Electron renderer 中没有 path 模块)
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
function normPath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '')
}
function resolveRelativePath(base: string, rel: string): string { function resolveRelativePath(base: string, rel: string): string {
const sep = base.includes('\\') ? '\\' : '/' const parts = normPath(base).split('/')
const parts = base.split(sep)
parts.pop() // 移除文件名 parts.pop() // 移除文件名
const relParts = rel.split('/') const relParts = rel.split('/')
for (const p of relParts) { for (const p of relParts) {
@@ -22,7 +26,7 @@ function resolveRelativePath(base: string, rel: string): string {
parts.push(p) parts.push(p)
} }
} }
return parts.join(sep) return parts.join('/')
} }
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 // 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
@@ -31,8 +35,6 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
if (!filePath) return if (!filePath) return
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
const sep: string = dir.includes('\\') ? '\\' : '/'
const normalizedDir = dir + sep
function visit(node: Element | Root): void { function visit(node: Element | Root): void {
if (!node.children) return if (!node.children) return
@@ -50,16 +52,12 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
} }
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外 // 解析完整路径并检查是否越界到 markdown 文件所在目录之外
const resolvedPath = resolveRelativePath(dir, src) const resolvedPath = resolveRelativePath(dir, src)
const normalizedResolved = resolvedPath.includes('\\') // E1: 统一规范化后比较,防止 ../ 路径越界
? resolvedPath.replace(/\\/g, '/') + '/' const normalizedBase = normPath(dir)
: resolvedPath + '/' if (!normPath(resolvedPath).startsWith(normalizedBase)) return
const normalizedBase = normalizedDir.includes('\\')
? normalizedDir.replace(/\\/g, '/')
: normalizedDir
if (!normalizedResolved.startsWith(normalizedBase + '/')) return
child.properties = { child.properties = {
...child.properties, ...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/') src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/')
} }
} }
} }
@@ -248,4 +248,70 @@ describe('tabStore', () => {
expect(updated.selectionEnd).toBe(20) expect(updated.selectionEnd).toBe(20)
}) })
}) })
describe('updateTabContent same-content guard (A3)', () => {
it('should not mark as modified when content is unchanged', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Same Content')
updateTabContent(tab.id, '# Same Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# Same Content')
expect(updated.isModified).toBe(false)
})
it('should mark as modified when content changes', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New')
expect(updated.isModified).toBe(true)
})
})
describe('moveTab (D1)', () => {
it('should move tab to a new position', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
const tab3 = createTab('/file3.md')
// tab1 移到末尾:期望 [tab2, tab3, tab1]
moveTab(tab1.id, 2)
const state = useTabStore.getState()
// 用引用相等而非基于全局计数器 ID
expect(state.tabs[0]).toBe(tab2)
expect(state.tabs[1]).toBe(tab3)
expect(state.tabs[2]).toBe(tab1)
})
it('should handle moving to same position (no-op)', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
moveTab(tab1.id, 0)
const state = useTabStore.getState()
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.tabs.length).toBe(2)
})
it('should handle moving non-existent tab gracefully', () => {
const { createTab, moveTab } = useTabStore.getState()
createTab('/file1.md')
moveTab('non-existent', 0)
const state = useTabStore.getState()
expect(state.tabs.length).toBe(1)
})
})
}) })
+28 -3
View File
@@ -33,6 +33,7 @@ interface TabState {
closeOtherTabs: (tabId: string) => void closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void closeAllTabs: () => void
closeTabsToRight: (tabId: string) => void closeTabsToRight: (tabId: string) => void
moveTab: (fromId: string, toIndex: number) => void
switchToTab: (tabId: string) => void switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void setModified: (tabId: string, modified: boolean) => void
@@ -203,6 +204,21 @@ export const useTabStore = create<TabState>((set, get) => {
_debouncedSaveToDB?.() _debouncedSaveToDB?.()
}, },
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
moveTab: (fromId: string, toIndex: number) => {
set(state => {
const fromIndex = state.tabs.findIndex(t => t.id === fromId)
if (fromIndex === -1 || fromIndex === toIndex) return state
const newTabs = [...state.tabs]
const [moved] = newTabs.splice(fromIndex, 1)
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
// toIndex 相对于原数组的位置在移除后无需调整)
newTabs.splice(toIndex, 0, moved)
return { tabs: newTabs }
})
_debouncedSaveToDB?.()
},
switchToTab: (tabId: string) => { switchToTab: (tabId: string) => {
set(state => { set(state => {
if (state.activeTabId === tabId) return state if (state.activeTabId === tabId) return state
@@ -216,9 +232,12 @@ export const useTabStore = create<TabState>((set, get) => {
updateTabContent: (tabId: string, content: string) => { updateTabContent: (tabId: string, content: string) => {
set(state => ({ set(state => ({
tabs: state.tabs.map(t => tabs: state.tabs.map(t => {
t.id === tabId ? { ...t, content, isModified: true } : t if (t.id !== tabId) return t
) // A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t
return { ...t, content, isModified: true }
})
})) }))
}, },
@@ -244,3 +263,9 @@ export const useTabStore = create<TabState>((set, get) => {
} }
} }
}) })
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
export function flushSaveToDB(): Promise<void> {
_debouncedSaveToDB?.cancel()
return getActualSaveToDB(useTabStore.getState)()
}
+76
View File
@@ -525,6 +525,15 @@ body {
margin: 4px 0; margin: 4px 0;
} }
/* D1: 标签拖拽排序样式 */
.tab-item.dragging {
opacity: 0.4;
}
.tab-item.drag-over {
border-left: 2px solid var(--primary);
}
/* Modified Banner */ /* Modified Banner */
#modified-banner { #modified-banner {
display: flex; display: flex;
@@ -604,6 +613,15 @@ body {
font-size: 11px; font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
transition: color 0.2s ease; transition: color 0.2s ease;
border: none;
background: transparent;
cursor: pointer;
font-family: var(--font-ui);
padding: 0;
}
.status-auto-save:hover {
color: var(--primary);
} }
.status-auto-save.saving { .status-auto-save.saving {
@@ -1521,6 +1539,56 @@ button:focus-visible,
border: 0; border: 0;
} }
/* ===== ErrorBoundary ===== */
.error-boundary-root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 2rem;
text-align: center;
font-family: var(--font-ui);
background: var(--bg);
color: var(--text);
}
.error-boundary-title {
margin-bottom: 1rem;
color: #e74c3c;
font-size: 1.25rem;
font-weight: 600;
}
.error-boundary-detail {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
max-width: 600px;
overflow: auto;
font-size: 0.875rem;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.error-boundary-reset {
margin-top: 1rem;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 6px;
background: var(--primary);
color: white;
cursor: pointer;
font-size: 1rem;
font-family: var(--font-ui);
transition: background 0.15s ease;
}
.error-boundary-reset:hover {
background: var(--primary-dark);
}
/* ===== Search & Replace Panel ===== */ /* ===== Search & Replace Panel ===== */
.search-replace-panel { .search-replace-panel {
@@ -1590,6 +1658,14 @@ button:focus-visible,
white-space: nowrap; white-space: nowrap;
} }
/* D6: 正则语法错误提示 */
.search-input-group .search-count {
color: #c5221f;
}
.search-input-group .search-count:not(:empty) {
/* 仅当有正则错误时变红 */
}
.search-btn { .search-btn {
display: flex; display: flex;
align-items: center; align-items: center;
+2 -7
View File
@@ -21,7 +21,7 @@ export interface IpcInvokeMap {
'window:forceClose': [void, void] 'window:forceClose': [void, void]
'window:cancelClose': [void, void] 'window:cancelClose': [void, void]
'dir:readTree': [string, ReadDirTreeResult] 'dir:readTree': [string, ReadDirTreeResult]
'dir:openDialog': [void, { canceled: boolean; filePaths: string[] }] 'dir:openDialog': [void, string | null]
'dir:watch': [string, void] 'dir:watch': [string, void]
'dir:unwatch': [void, void] 'dir:unwatch': [void, void]
} }
@@ -41,16 +41,11 @@ export interface ElectronAPI {
cancelClose: () => Promise<void> cancelClose: () => Promise<void>
openExternal: (url: string) => void openExternal: (url: string) => void
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult> readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }> openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void> watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void> unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onMenuSave: (callback: () => void) => Unsubscribe
onMenuSaveAs: (callback: () => void) => Unsubscribe
onViewModeChange: (callback: (mode: string) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => Unsubscribe onConfirmClose: (callback: () => void) => Unsubscribe
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用 // 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.3.8' export const APP_VERSION = 'v0.3.10'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([ export const SKIP_DIRS = new Set([
-3
View File
@@ -19,9 +19,6 @@ export const IPC_CHANNELS = {
// 主进程 → 渲染进程 (send) // 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab', FILE_OPEN_IN_TAB: 'file:openInTab',
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified', FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
MENU_SAVE: 'menu:save',
MENU_SAVE_AS: 'menu:saveAs',
MENU_VIEW_MODE: 'menu:viewMode',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose', WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged' SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
} as const } as const