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:
@@ -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`)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Vendored
+4
-48
@@ -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 {
|
||||||
|
|||||||
@@ -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="行内代码"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
updateTabContent(activeTabId, newValue)
|
||||||
if (activeTabId) {
|
setModified(activeTabId, true)
|
||||||
updateTabContent(activeTabId, newValue)
|
pendingSelectionRef.current = newPos
|
||||||
setModified(activeTabId, true)
|
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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) : '未命名'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,22 +15,19 @@ 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]) {
|
setRootPath(dirPath)
|
||||||
const dirPath = result.filePaths[0]
|
expandDirs([dirPath])
|
||||||
setRootPath(dirPath)
|
setLoading('dir-load', true)
|
||||||
expandDirs([dirPath])
|
try {
|
||||||
setLoading('dir-load', true)
|
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||||
try {
|
if (dirTree.success && dirTree.tree) {
|
||||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
setTree(dirTree.tree)
|
||||||
if (dirTree.success && dirTree.tree) {
|
window.electronAPI.watchDir(dirPath)
|
||||||
setTree(dirTree.tree)
|
|
||||||
window.electronAPI.watchDir(dirPath)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading('dir-load', false)
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading('dir-load', false)
|
||||||
}
|
}
|
||||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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([
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user