From b65e34e28876da1351ec5b285d0f7bc11cd91e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?= <1440196015@qq.com> Date: Tue, 23 Jun 2026 10:47:45 +0800 Subject: [PATCH] =?UTF-8?q?v0.3.10:=20=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20=E2=80=94=2017=E9=A1=B9Bug=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D/=E7=A8=B3=E5=AE=9A=E6=80=A7/=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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✅ --- DESIGN.md | 66 +++++++++------ README.md | 35 +++++--- package.json | 2 +- src/preload/index.ts | 35 +++----- src/preload/preload.d.ts | 52 +----------- src/renderer/App.tsx | 9 +- .../components/Editor/EditorToolbar.tsx | 3 +- src/renderer/components/Editor/useMilkdown.ts | 65 ++++++++++++++- .../ErrorBoundary/ErrorBoundary.tsx | 48 ++--------- .../SearchReplace/SearchReplace.tsx | 76 ++++++++++++++--- src/renderer/components/Sidebar/Sidebar.tsx | 22 ++++- .../components/SourceEditor/SourceEditor.tsx | 50 ++++++----- .../components/StatusBar/StatusBar.tsx | 17 ++-- src/renderer/components/TabBar/TabBar.tsx | 54 +++++++++++- src/renderer/hooks/useActiveHeading.ts | 82 +++++++++++++++++++ src/renderer/hooks/useFolderOperations.ts | 27 +++--- src/renderer/hooks/useIpcListeners.ts | 15 +--- src/renderer/hooks/useUnsavedWarning.ts | 8 +- src/renderer/lib/__tests__/fileUtils.test.ts | 8 +- src/renderer/lib/fileUtils.ts | 4 +- src/renderer/lib/markdown.ts | 24 +++--- .../stores/__tests__/tabStore.test.ts | 68 ++++++++++++++- src/renderer/stores/tabStore.ts | 31 ++++++- src/renderer/styles/global.css | 76 +++++++++++++++++ src/renderer/types/ipc.ts | 9 +- src/shared/constants.ts | 2 +- src/shared/ipc-channels.ts | 3 - 27 files changed, 639 insertions(+), 252 deletions(-) create mode 100644 src/renderer/hooks/useActiveHeading.ts diff --git a/DESIGN.md b/DESIGN.md index b4ce514..9fbe047 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,8 +1,8 @@ -# MarkLite v0.1.1 — 架构设计文档 +# MarkLite v0.3.10 — 架构设计文档 ## 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 核心原则 @@ -20,7 +20,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程 | 桌面框架 | Electron | v28 | 跨平台桌面应用框架 | | 前端框架 | React | v18 | 函数组件 + Hooks | | 类型系统 | TypeScript | v5.6 | 全量类型安全 | -| 编辑器 | CodeMirror | v6 | 现代化代码编辑器 | +| 编辑器 | Milkdown | v7 | WYSIWYG 编辑器 + 源码编辑模式 | | 状态管理 | Zustand | v5 | 轻量级状态管理 | | 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 | | Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 | @@ -44,29 +44,36 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程 ┌───────────────────────▼──────────────────────────────────────┐ │ Preload Script (src/preload/) 1 文件 │ │ index.ts contextBridge 类型安全暴露 │ -│ electronAPI 22 个方法/事件的类型安全接口 │ +│ electronAPI 18 个方法/事件的类型安全接口 │ └───────────────────────┬──────────────────────────────────────┘ │ ┌───────────────────────▼──────────────────────────────────────┐ │ Renderer Process (src/renderer/) React 18 │ │ │ -│ components/ (11) Toolbar · TabBar · Editor · Preview │ -│ Sidebar · StatusBar · WelcomeScreen │ -│ Toast · ModifiedBanner · DropOverlay │ +│ components/ (23) Toolbar · TabBar · Editor · EditorToolbar │ +│ SourceEditor · Preview · Sidebar · FileTree│ +│ OutlinePanel · StatusBar · WelcomeScreen │ +│ Toast · ConfirmDialog · ModifiedBanner │ +│ SearchReplace · DropOverlay · ErrorBoundary│ +│ AboutDialog · LoadingSpinner · Icons │ │ │ │ stores/ (3) tabStore · editorStore · sidebarStore │ -│ hooks/ (6) useTheme · useSettings · useKeyboard │ -│ useDragDrop · useFileWatch · useUnsaved │ -│ lib/ (3) markdown · fileUtils · constants │ -│ db/ (4) schema · tab · settings · recentFiles │ -│ types/ (6) tab · file · settings · ipc · index │ +│ hooks/ (19) useTheme · useSettings · useSettingsInit │ +│ useKeyboard · useDragDrop · useFileWatch │ +│ useAutoSave · useIpcListeners ... │ +│ lib/ (4) markdown · fileUtils · errorHandler │ +│ constants │ +│ db/ (4) schema · tabRepository · settingsRepo │ +│ recentFilesRepository │ +│ types/ (5) tab · file · settings · ipc · index │ │ styles/ (3) variables · global · markdown-body │ └──────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────┐ -│ Shared (src/shared/) 2 文件 │ +│ Shared (src/shared/) 3 文件 │ │ ipc-channels.ts IPC 通道名常量 │ │ types.ts 共享类型定义 │ +│ constants.ts 共享常量 (版本号、文件大小限制等) │ └──────────────────────────────────────────────────────────────┘ ``` @@ -205,21 +212,30 @@ db.version(1).stores({ | `window:confirmClose` | 无 | 请求确认关闭 | | `sidebar:dirChanged` | 无 | 目录结构变化 | -## 6. 编辑器架构 — CodeMirror 6 +## 6. 编辑器架构 — Milkdown v7 (WYSIWYG) + SourceEditor (textarea) -### 6.1 功能特性 +### 6.1 双编辑模式 -- Markdown 语法高亮 -- 行号显示 -- 代码折叠 (foldGutter) -- 括号匹配 (bracketMatching) -- 搜索替换 (search) — 中文本地化 -- 历史记录 (history) — 支持 undo/redo -- Tab 缩进 (indentWithTab) -- 自动换行 (lineWrapping) -- 暗色主题 (oneDark) +- **编辑模式 (Milkdown)**:基于 ProseMirror 的 WYSIWYG Markdown 编辑器,支持格式化工具栏(粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/分割线)、搜索替换面板(含正则支持)、自动配对括号/引号、undo/redo +- **源码模式 (SourceEditor)**:原生 textarea 控制 Markdown 原文,支持 Tab 缩进、Ctrl+B/I 快捷键 -### 6.2 滚动与选区持久化 +两种模式共享同一 tabStore 数据源,可随时切换。 + +### 6.2 插件体系 + +| 插件 | 说明 | +|------|------| +| commonmark | 基础 Markdown 语法 | +| gfm | GitHub Flavored Markdown(表格/任务列表/删除线等) | +| history | undo/redo | +| listener | 内容变更监听 | +| indent | Tab 缩进 | +| trailing | 尾随换行 | +| clipboard | 剪贴板增强 | +| searchPlugin (自研) | 搜索高亮装饰 | +| autoPairPlugin (自研) | 自动配对括号/引号 | + +### 6.3 滚动与选区持久化 切换标签时自动保存/恢复: - 滚动位置 (`scrollTop`) diff --git a/README.md b/README.md index 1ac84d6..09139ab 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ TypeScript React License - Version + Version

@@ -30,7 +30,7 @@ |------|------| | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | -| ✏️ **实时编辑** | 左侧编辑器(CodeMirror 6),支持 Tab 缩进、行号显示、折叠、括号匹配 | +| ✏️ **双编辑模式** | Milkdown WYSIWYG 编辑器(格式化工具栏/搜索替换/正则/自动配对)+ 源码模式(textarea)| | 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | | 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 | @@ -131,6 +131,7 @@ npm run test:coverage | `Ctrl + Shift + S` | 另存为 | | `Ctrl + 1` | 编辑模式 | | `Ctrl + 2` | 预览模式 | +| `Ctrl + 3` | 源码模式 | | `Ctrl + F` | 搜索 | | `Ctrl + H` | 搜索并替换 | | `Ctrl + B` | 粗体 | @@ -145,7 +146,7 @@ npm run test:coverage | 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | | 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | | 类型系统 | [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 | 轻量级状态管理 | | 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | | Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | @@ -182,17 +183,25 @@ MarkLite/ │ │ ├── main.tsx # React 入口 │ │ ├── App.tsx # 根组件:布局编排、全局事件 │ │ │ -│ │ ├── components/ # UI 组件 +│ │ ├── components/ # UI 组件 (23个) │ │ │ ├── Toolbar/ # 工具栏 -│ │ │ ├── TabBar/ # 标签页栏 -│ │ │ ├── Editor/ # CodeMirror 6 编辑器 +│ │ │ ├── TabBar/ # 标签页栏 (含拖拽排序) +│ │ │ ├── Editor/ # Milkdown 编辑器 + 工具栏 │ │ │ ├── Preview/ # Markdown 预览面板 +│ │ │ ├── SourceEditor/ # 源码编辑模式 (textarea) │ │ │ ├── Sidebar/ # 侧边栏文件树 -│ │ │ ├── StatusBar/ # 状态栏 +│ │ │ ├── FileTree/ # 递归文件树 +│ │ │ ├── OutlinePanel/ # 文档大纲 (活跃标题高亮) +│ │ │ ├── StatusBar/ # 状态栏 (自动保存开关) +│ │ │ ├── SearchReplace/ # 搜索替换面板 (支持正则) │ │ │ ├── WelcomeScreen/ # 欢迎屏幕 │ │ │ ├── Toast/ # Toast 通知 +│ │ │ ├── ConfirmDialog/ # 确认对话框 │ │ │ ├── ModifiedBanner/ # 文件外部修改提示 │ │ │ ├── DropOverlay/ # 拖拽文件覆盖层 +│ │ │ ├── ErrorBoundary/ # 错误边界 +│ │ │ ├── AboutDialog/ # 关于对话框 +│ │ │ ├── LoadingSpinner/ # 加载指示器 │ │ │ └── Icons.tsx # SVG 图标库 │ │ │ │ │ ├── stores/ # Zustand 状态管理 @@ -200,17 +209,23 @@ MarkLite/ │ │ │ ├── editorStore.ts # 编辑器状态 │ │ │ └── sidebarStore.ts # 侧边栏状态 │ │ │ -│ │ ├── hooks/ # 自定义 Hooks +│ │ ├── hooks/ # 自定义 Hooks (19个) │ │ │ ├── useTheme.ts # 暗色/亮色主题 -│ │ │ ├── useSettings.ts # 用户设置 +│ │ │ ├── useSettings.ts # 视图模式 +│ │ │ ├── useSettingsInit.ts # 设置初始加载 │ │ │ ├── useKeyboard.ts # 全局快捷键 │ │ │ ├── useDragDrop.ts # 拖拽打开 │ │ │ ├── useFileWatch.ts # 外部修改监听 -│ │ │ └── useUnsavedWarning.ts # 未保存提醒 +│ │ │ ├── useUnsavedWarning.ts # 未保存提醒 +│ │ │ ├── useAutoSave.ts # 自动保存 +│ │ │ ├── useAutoExpandDir.ts # 自动展开目录 +│ │ │ ├── useActiveHeading.ts # 活跃标题追踪 +│ │ │ └── ... # 文件操作、IPC监听等 │ │ │ │ │ ├── lib/ # 工具库 │ │ │ ├── markdown.ts # Markdown 渲染管线 │ │ │ ├── fileUtils.ts # 文件工具函数 +│ │ │ ├── errorHandler.ts # 错误处理 │ │ │ └── constants.ts # 常量定义 │ │ │ │ │ ├── db/ # IndexedDB 持久化层 diff --git a/package.json b/package.json index 32aa2da..e09e776 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.3.8", + "version": "0.3.10", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { diff --git a/src/preload/index.ts b/src/preload/index.ts index d301e9b..21db7d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,12 +1,14 @@ import { contextBridge, ipcRenderer, shell } from 'electron' 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 openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE), readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath), - saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), - saveFileAs: (data: { content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), + saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), + saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH), getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath), reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD), @@ -37,39 +39,26 @@ contextBridge.exposeInMainWorld('electronAPI', { unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH), // 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) ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } }, - onMenuSave: (callback: () => void) => { - 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) => { + onExternalModification: (callback) => { const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } }, - onDirChanged: (callback: () => void) => { + onDirChanged: (callback) => { const handler = () => callback() ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } }, - onConfirmClose: (callback: () => void) => { + onConfirmClose: (callback) => { const handler = () => callback() ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } } -}) +} + +contextBridge.exposeInMainWorld('electronAPI', api) diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts index cb3b537..e337233 100644 --- a/src/preload/preload.d.ts +++ b/src/preload/preload.d.ts @@ -1,55 +1,11 @@ /** * DX-02: Preload 类型安全声明 - * 为 window.electronAPI 提供完整的 TypeScript 类型定义, - * 确保渲染进程访问 API 时有完整的类型提示和编译时检查。 + * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义, + * preload/index.ts 引入该类型做编译期契约检查。 + * 本文件仅保留全局 Window 增强声明。 */ -import type { - OpenFileResponse, - ReadFileResult, - SaveFilePayload, - SaveAsPayload, - SaveFileResult, - ReloadFileResult, - FileStatsResult, - ReadDirTreeResult, -} from '../shared/types' - -interface ElectronAPI { - // File operations - openFile: () => Promise - readFile: (filePath: string) => Promise - saveFile: (data: SaveFilePayload) => Promise - saveFileAs: (data: SaveAsPayload) => Promise - getCurrentPath: () => Promise - getFileStats: (filePath: string) => Promise - reloadFile: () => Promise - - // Tab management - tabSwitched: (filePath: string | null) => Promise - - // Window control - forceClose: () => Promise - cancelClose: () => Promise - - // Shell - openExternal: (url: string) => void - - // File Tree (Sidebar) - readDirTree: (dirPath: string) => Promise - openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }> - watchDir: (dirPath: string) => Promise - unwatchDir: () => Promise - - // 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 -} +import type { ElectronAPI } from '../renderer/types/ipc' declare global { interface Window { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index bb9ced8..c5896dc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react' import { useTabStore } from './stores/tabStore' +import { flushSaveToDB } from './stores/tabStore' import { useEditorStore } from './stores/editorStore' import { useTheme } from './hooks/useTheme' import { useSettings } from './hooks/useSettings' @@ -51,7 +52,7 @@ export function App() { const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast) useDragDrop(showToast) useFileWatch() - const { isAutoSaving, autoSaveEnabled } = useAutoSave() + const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave() // UX-01: 传入 confirm 函数替代原生 confirm() const handleConfirmClose = useCallback(async (message: string): Promise => { @@ -64,9 +65,9 @@ export function App() { }) }, [confirm]) - useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose) + useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB) useKeyboard(handleOpenFile, handleSave, handleSaveAs) - useIpcListeners(handleSave, handleSaveAs) + useIpcListeners() useEffect(() => { if (!window.electronAPI) return @@ -117,7 +118,7 @@ export function App() { )} - + {showAbout && } diff --git a/src/renderer/components/Editor/EditorToolbar.tsx b/src/renderer/components/Editor/EditorToolbar.tsx index e9bd26d..3caaaa1 100644 --- a/src/renderer/components/Editor/EditorToolbar.tsx +++ b/src/renderer/components/Editor/EditorToolbar.tsx @@ -10,6 +10,7 @@ import { wrapInBulletListCommand, wrapInOrderedListCommand, createCodeBlockCommand, + toggleInlineCodeCommand, insertImageCommand, toggleLinkCommand, insertHrCommand @@ -63,7 +64,7 @@ export const EditorToolbar = React.memo(function EditorToolbar({ action }: Edito diff --git a/src/renderer/components/SearchReplace/SearchReplace.tsx b/src/renderer/components/SearchReplace/SearchReplace.tsx index 85d7ec4..794882d 100644 --- a/src/renderer/components/SearchReplace/SearchReplace.tsx +++ b/src/renderer/components/SearchReplace/SearchReplace.tsx @@ -13,13 +13,37 @@ interface SearchReplaceProps { /** * 在 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[] = [] 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) => { if (!node.isText) return true const text = node.text || '' @@ -82,6 +106,8 @@ export const SearchReplace = memo(function SearchReplace({ const [replacement, setReplacement] = useState('') const [showReplace, setShowReplace] = useState(false) const [caseSensitive, setCaseSensitive] = useState(false) + const [useRegex, setUseRegex] = useState(false) + const [regexError, setRegexError] = useState(false) const [matchCount, setMatchCount] = useState(0) const [currentMatch, setCurrentMatch] = useState(-1) @@ -92,17 +118,30 @@ export const SearchReplace = memo(function SearchReplace({ const matchesRef = useRef([]) const currentIndexRef = useRef(-1) const caseSensitiveRef = useRef(false) + const useRegexRef = useRef(false) const queryRef = useRef('') // Sync refs caseSensitiveRef.current = caseSensitive + useRegexRef.current = useRegex queryRef.current = query // 执行搜索 - const doSearch = useCallback((searchQuery: string, cs: boolean) => { + const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => { const view = getView() if (!view) return + // D6: 检查正则语法 + setRegexError(false) + if (rx && searchQuery.trim()) { + try { + new RegExp(searchQuery, 'g') + } catch { + setRegexError(true) + return + } + } + if (!searchQuery.trim()) { clearSearchDecorations(view) matchesRef.current = [] @@ -112,7 +151,7 @@ export const SearchReplace = memo(function SearchReplace({ return } - const matches = findMatches(view.state.doc, searchQuery, cs) + const matches = findMatches(view.state.doc, searchQuery, cs, rx) matchesRef.current = matches const newIdx = matches.length > 0 ? 0 : -1 currentIndexRef.current = newIdx @@ -133,14 +172,21 @@ export const SearchReplace = memo(function SearchReplace({ const handleQueryChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value setQuery(value) - doSearch(value, caseSensitiveRef.current) + doSearch(value, caseSensitiveRef.current, useRegexRef.current) }, [doSearch]) // 大小写切换 const toggleCaseSensitive = useCallback(() => { const newCS = !caseSensitiveRef.current 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]) // 导航到下一个/上一个匹配 @@ -196,7 +242,7 @@ export const SearchReplace = memo(function SearchReplace({ // Re-search after replacement (document changed) // Use requestAnimationFrame to let ProseMirror process the transaction requestAnimationFrame(() => { - doSearch(queryRef.current, caseSensitiveRef.current) + doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current) }) }, [getView, replacement, doSearch]) @@ -206,7 +252,8 @@ export const SearchReplace = memo(function SearchReplace({ 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 // 从后往前替换以保持位置正确 @@ -221,7 +268,7 @@ export const SearchReplace = memo(function SearchReplace({ // 替换后重新搜索 requestAnimationFrame(() => { - doSearch(queryRef.current, caseSensitiveRef.current) + doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current) }) }, [getView, replacement, doSearch]) @@ -300,7 +347,7 @@ export const SearchReplace = memo(function SearchReplace({ aria-label="搜索文本" /> - {matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''} + {regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')} + | )} diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 1a7e025..066bcbd 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -21,9 +21,12 @@ export const TabBar = React.memo(function TabBar() { const closeOtherTabs = useTabStore(s => s.closeOtherTabs) const closeAllTabs = useTabStore(s => s.closeAllTabs) const closeTabsToRight = useTabStore(s => s.closeTabsToRight) + const moveTab = useTabStore(s => s.moveTab) const tabListRef = useRef(null) const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, tabId: '' }) + const [dragOverIndex, setDragOverIndex] = useState(null) + const dragTabIdRef = useRef(null) const { confirm, confirmDialogProps } = useConfirm() // 滚动到活动标签 @@ -173,6 +176,46 @@ export const TabBar = React.memo(function TabBar() { setMenu(prev => ({ ...prev, visible: false })) }, [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 index = tabs.findIndex(t => t.id === menu.tabId) return index < tabs.length - 1 @@ -184,15 +227,22 @@ export const TabBar = React.memo(function TabBar() { <>

- {tabs.map(tab => ( + {tabs.map((tab, index) => (
switchToTab(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} > {tab.filePath ? getFileName(tab.filePath) : '未命名'} diff --git a/src/renderer/hooks/useActiveHeading.ts b/src/renderer/hooks/useActiveHeading.ts new file mode 100644 index 0000000..727bcea --- /dev/null +++ b/src/renderer/hooks/useActiveHeading.ts @@ -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, + headings: { level: number; text: string }[] +): number | null { + const [activeIndex, setActiveIndex] = useState(null) + const observerRef = useRef(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 +} diff --git a/src/renderer/hooks/useFolderOperations.ts b/src/renderer/hooks/useFolderOperations.ts index df6cedb..c1e6f2d 100644 --- a/src/renderer/hooks/useFolderOperations.ts +++ b/src/renderer/hooks/useFolderOperations.ts @@ -15,22 +15,19 @@ export function useFolderOperations() { const handleOpenFolder = useCallback(async () => { if (!window.electronAPI) return - const result = await window.electronAPI.openFolderDialog() - if (!result) return - if (!result.canceled && result.filePaths[0]) { - const dirPath = result.filePaths[0] - setRootPath(dirPath) - expandDirs([dirPath]) - setLoading('dir-load', true) - try { - const dirTree = await window.electronAPI.readDirTree(dirPath) - if (dirTree.success && dirTree.tree) { - setTree(dirTree.tree) - window.electronAPI.watchDir(dirPath) - } - } finally { - setLoading('dir-load', false) + const dirPath = await window.electronAPI.openFolderDialog() + if (!dirPath) return + setRootPath(dirPath) + expandDirs([dirPath]) + setLoading('dir-load', true) + try { + const dirTree = await window.electronAPI.readDirTree(dirPath) + if (dirTree.success && dirTree.tree) { + setTree(dirTree.tree) + window.electronAPI.watchDir(dirPath) } + } finally { + setLoading('dir-load', false) } }, [setRootPath, setTree, expandDirs, setLoading]) diff --git a/src/renderer/hooks/useIpcListeners.ts b/src/renderer/hooks/useIpcListeners.ts index f77bb1c..b0a6c2e 100644 --- a/src/renderer/hooks/useIpcListeners.ts +++ b/src/renderer/hooks/useIpcListeners.ts @@ -1,17 +1,13 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { useTabStore } from '../stores/tabStore' import { recentFilesRepository } from '../db/recentFilesRepository' /** * 主进程事件注册 hook - * 处理菜单触发的文件打开、保存、另存为事件 + * 处理通过命令行或文件关联打开的文件 */ -export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) { +export function useIpcListeners() { const createTab = useTabStore(s => s.createTab) - const handleSaveRef = useRef(handleSave) - const handleSaveAsRef = useRef(handleSaveAs) - handleSaveRef.current = handleSave - handleSaveAsRef.current = handleSaveAs useEffect(() => { if (!window.electronAPI) return @@ -21,9 +17,6 @@ export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void if (data.filePath) recentFilesRepository.add(data.filePath) setTimeout(() => useTabStore.getState().saveToDB(), 100) } - const unsub1 = api.onFileOpenInTab(onOpen) - const unsub2 = api.onMenuSave(() => handleSaveRef.current()) - const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current()) - return () => { unsub1(); unsub2(); unsub3() } + return api.onFileOpenInTab(onOpen) }, [createTab]) } diff --git a/src/renderer/hooks/useUnsavedWarning.ts b/src/renderer/hooks/useUnsavedWarning.ts index fe5477e..5e00b66 100644 --- a/src/renderer/hooks/useUnsavedWarning.ts +++ b/src/renderer/hooks/useUnsavedWarning.ts @@ -6,10 +6,14 @@ import { useEffect, useCallback, useRef } from 'react' */ export function useUnsavedWarning( hasUnsaved: () => boolean, - confirmFn?: (message: string) => Promise + confirmFn?: (message: string) => Promise, + // D5: 关闭前回调(flush 待保存数据) + onBeforeForceClose?: () => Promise ) { const confirmFnRef = useRef(confirmFn) confirmFnRef.current = confirmFn + const onBeforeForceCloseRef = useRef(onBeforeForceClose) + onBeforeForceCloseRef.current = onBeforeForceClose const doConfirm = useCallback(async (message: string): Promise => { if (confirmFnRef.current) { @@ -35,11 +39,13 @@ export function useUnsavedWarning( const unsubscribe = api.onConfirmClose(async () => { if (!hasUnsaved()) { + await onBeforeForceCloseRef.current?.() api.forceClose() return } const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?') if (shouldClose) { + await onBeforeForceCloseRef.current?.() api.forceClose() } else { api.cancelClose() diff --git a/src/renderer/lib/__tests__/fileUtils.test.ts b/src/renderer/lib/__tests__/fileUtils.test.ts index 0836033..950a4d4 100644 --- a/src/renderer/lib/__tests__/fileUtils.test.ts +++ b/src/renderer/lib/__tests__/fileUtils.test.ts @@ -16,8 +16,12 @@ describe('fileUtils', () => { }) it('should handle path with trailing slash', () => { - // getFileName uses pop() || filePath, empty string is falsy so returns filePath - expect(getFileName('/path/to/dir/')).toBe('/path/to/dir/') + expect(getFileName('/path/to/dir/')).toBe('dir') + }) + + + it('should handle path with trailing backslash', () => { + expect(getFileName('C:\\Users\\test\\')).toBe('test') }) it('should handle mixed path separators', () => { diff --git a/src/renderer/lib/fileUtils.ts b/src/renderer/lib/fileUtils.ts index e592684..3f4150f 100644 --- a/src/renderer/lib/fileUtils.ts +++ b/src/renderer/lib/fileUtils.ts @@ -1,7 +1,9 @@ import { ALLOWED_EXTENSIONS } from './constants' 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 { diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index b699ee5..fac4047 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -9,9 +9,13 @@ import rehypeHighlight from 'rehype-highlight' import type { Element, Root } from 'hast' // 简单的路径解析(Electron renderer 中没有 path 模块) +// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判 +function normPath(p: string): string { + return p.replace(/\\/g, '/').replace(/\/+$/, '') +} + function resolveRelativePath(base: string, rel: string): string { - const sep = base.includes('\\') ? '\\' : '/' - const parts = base.split(sep) + const parts = normPath(base).split('/') parts.pop() // 移除文件名 const relParts = rel.split('/') for (const p of relParts) { @@ -22,7 +26,7 @@ function resolveRelativePath(base: string, rel: string): string { parts.push(p) } } - return parts.join(sep) + return parts.join('/') } // 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 @@ -31,8 +35,6 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { if (!filePath) return const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') - const sep: string = dir.includes('\\') ? '\\' : '/' - const normalizedDir = dir + sep function visit(node: Element | Root): void { if (!node.children) return @@ -50,16 +52,12 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { } // 解析完整路径并检查是否越界到 markdown 文件所在目录之外 const resolvedPath = resolveRelativePath(dir, src) - const normalizedResolved = resolvedPath.includes('\\') - ? resolvedPath.replace(/\\/g, '/') + '/' - : resolvedPath + '/' - const normalizedBase = normalizedDir.includes('\\') - ? normalizedDir.replace(/\\/g, '/') - : normalizedDir - if (!normalizedResolved.startsWith(normalizedBase + '/')) return + // E1: 统一规范化后比较,防止 ../ 路径越界 + const normalizedBase = normPath(dir) + if (!normPath(resolvedPath).startsWith(normalizedBase)) return child.properties = { ...child.properties, - src: 'file://' + (dir + sep + src).replace(/\\/g, '/') + src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/') } } } diff --git a/src/renderer/stores/__tests__/tabStore.test.ts b/src/renderer/stores/__tests__/tabStore.test.ts index 46448d4..828f611 100644 --- a/src/renderer/stores/__tests__/tabStore.test.ts +++ b/src/renderer/stores/__tests__/tabStore.test.ts @@ -248,4 +248,70 @@ describe('tabStore', () => { 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) + }) + }) +}) \ No newline at end of file diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 33eec4f..d807626 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -33,6 +33,7 @@ interface TabState { closeOtherTabs: (tabId: string) => void closeAllTabs: () => void closeTabsToRight: (tabId: string) => void + moveTab: (fromId: string, toIndex: number) => void switchToTab: (tabId: string) => void updateTabContent: (tabId: string, content: string) => void setModified: (tabId: string, modified: boolean) => void @@ -203,6 +204,21 @@ export const useTabStore = create((set, get) => { _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) => { set(state => { if (state.activeTabId === tabId) return state @@ -216,9 +232,12 @@ export const useTabStore = create((set, get) => { updateTabContent: (tabId: string, content: string) => { set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, content, isModified: true } : t - ) + tabs: state.tabs.map(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((set, get) => { } } }) + +// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入) +export function flushSaveToDB(): Promise { + _debouncedSaveToDB?.cancel() + return getActualSaveToDB(useTabStore.getState)() +} diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index 186a4a9..349bf27 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -525,6 +525,15 @@ body { margin: 4px 0; } +/* D1: 标签拖拽排序样式 */ +.tab-item.dragging { + opacity: 0.4; +} + +.tab-item.drag-over { + border-left: 2px solid var(--primary); +} + /* Modified Banner */ #modified-banner { display: flex; @@ -604,6 +613,15 @@ body { font-size: 11px; color: var(--text-tertiary); 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 { @@ -1521,6 +1539,56 @@ button:focus-visible, 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 { @@ -1590,6 +1658,14 @@ button:focus-visible, white-space: nowrap; } +/* D6: 正则语法错误提示 */ +.search-input-group .search-count { + color: #c5221f; +} +.search-input-group .search-count:not(:empty) { + /* 仅当有正则错误时变红 */ +} + .search-btn { display: flex; align-items: center; diff --git a/src/renderer/types/ipc.ts b/src/renderer/types/ipc.ts index aa4abf9..c8809f1 100644 --- a/src/renderer/types/ipc.ts +++ b/src/renderer/types/ipc.ts @@ -21,7 +21,7 @@ export interface IpcInvokeMap { 'window:forceClose': [void, void] 'window:cancelClose': [void, void] 'dir:readTree': [string, ReadDirTreeResult] - 'dir:openDialog': [void, { canceled: boolean; filePaths: string[] }] + 'dir:openDialog': [void, string | null] 'dir:watch': [string, void] 'dir:unwatch': [void, void] } @@ -41,16 +41,11 @@ export interface ElectronAPI { cancelClose: () => Promise openExternal: (url: string) => void readDirTree: (dirPath: string) => Promise - openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }> + openFolderDialog: () => Promise watchDir: (dirPath: string) => Promise unwatchDir: () => Promise 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 onDirChanged: (callback: () => void) => Unsubscribe onConfirmClose: (callback: () => void) => Unsubscribe } - - diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d08ffd9..cbfc5b4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const export const SKIP_DIRS = new Set([ diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index fa7d61d..5d45556 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -19,9 +19,6 @@ export const IPC_CHANNELS = { // 主进程 → 渲染进程 (send) FILE_OPEN_IN_TAB: 'file:openInTab', FILE_EXTERNALLY_MODIFIED: 'file:externallyModified', - MENU_SAVE: 'menu:save', - MENU_SAVE_AS: 'menu:saveAs', - MENU_VIEW_MODE: 'menu:viewMode', WINDOW_CONFIRM_CLOSE: 'window:confirmClose', SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged' } as const