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. 项目概述
|
||||
|
||||
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`)
|
||||
|
||||
@@ -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/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||
<img src="https://img.shields.io/badge/Version-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 align="center">
|
||||
@@ -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 持久化层
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+12
-23
@@ -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)
|
||||
|
||||
Vendored
+4
-48
@@ -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<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
|
||||
}
|
||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
@@ -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<boolean> => {
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} />
|
||||
<StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} onToggleAutoSave={toggleAutoSave} />
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(createCodeBlockCommand)}
|
||||
onClick={() => exec(toggleInlineCodeCommand)}
|
||||
title="行内代码"
|
||||
aria-label="行内代码"
|
||||
>
|
||||
|
||||
@@ -18,6 +18,67 @@ import { Plugin, PluginKey } from '@milkdown/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
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 ---
|
||||
|
||||
export interface SearchMatch {
|
||||
@@ -105,8 +166,8 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
||||
ctx.set(rootCtx, containerRef.current!)
|
||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||
|
||||
// Inject search highlight plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
|
||||
// Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
|
||||
|
||||
// Configure listener for content changes
|
||||
const lm = ctx.get(listenerCtx)
|
||||
|
||||
@@ -35,52 +35,20 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
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>
|
||||
<div className="error-boundary-root" role="alert">
|
||||
<h2 className="error-boundary-title">应用遇到了错误</h2>
|
||||
{isDev && (
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<pre className="error-boundary-detail">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
<button className="error-boundary-reset" onClick={this.handleReset}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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<SearchMatch[]>([])
|
||||
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<HTMLInputElement>) => {
|
||||
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="搜索文本"
|
||||
/>
|
||||
<span className="search-count" aria-live="polite">
|
||||
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''}
|
||||
{regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -312,6 +359,15 @@ export const SearchReplace = memo(function SearchReplace({
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className={`search-btn ${useRegex ? 'active' : ''}`}
|
||||
onClick={toggleRegex}
|
||||
title="使用正则表达式 (点击切换)"
|
||||
aria-label="正则表达式"
|
||||
aria-pressed={useRegex}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
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 { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
@@ -8,6 +8,7 @@ import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
import { useActiveHeading } from '../../hooks/useActiveHeading'
|
||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||
import type { Heading } from '../OutlinePanel'
|
||||
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
||||
@@ -33,6 +34,7 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
// Parse headings from active tab content
|
||||
@@ -41,6 +43,22 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
return parseHeadings(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
|
||||
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
|
||||
const view = getEditorView()
|
||||
@@ -149,7 +167,7 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
<OutlinePanel
|
||||
headings={headings}
|
||||
onNavigate={handleHeadingNavigate}
|
||||
activeHeadingIndex={null}
|
||||
activeHeadingIndex={activeHeadingIndex}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
interface SourceEditorProps {
|
||||
@@ -8,6 +8,9 @@ interface SourceEditorProps {
|
||||
/**
|
||||
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
|
||||
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
|
||||
*
|
||||
* A2: 完全受控 — 所有变更通过 updateTabContent 驱动,
|
||||
* 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。
|
||||
*/
|
||||
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
|
||||
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 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>) => {
|
||||
if (!activeTabId) return
|
||||
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 isCtrl = e.ctrlKey || e.metaKey
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || !activeTabId) return
|
||||
|
||||
// Tab 插入两个空格而非跳转焦点
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const value = textarea.value
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end)
|
||||
textarea.value = newValue
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2
|
||||
if (activeTabId) {
|
||||
const newPos = start + 2
|
||||
updateTabContent(activeTabId, newValue)
|
||||
setModified(activeTabId, true)
|
||||
}
|
||||
pendingSelectionRef.current = newPos
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+B: 粗体
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || !activeTabId) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const value = textarea.value
|
||||
const selected = value.substring(start, 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)
|
||||
setModified(activeTabId, true)
|
||||
pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+I: 斜体
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || !activeTabId) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const value = textarea.value
|
||||
const selected = value.substring(start, 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)
|
||||
setModified(activeTabId, true)
|
||||
pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1
|
||||
return
|
||||
}
|
||||
}, [activeTabId, updateTabContent, setModified])
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
interface StatusBarProps {
|
||||
isAutoSaving?: boolean
|
||||
autoSaveEnabled?: boolean
|
||||
onToggleAutoSave?: () => void
|
||||
}
|
||||
|
||||
export const StatusBar = React.memo(function StatusBar({
|
||||
isAutoSaving = false,
|
||||
autoSaveEnabled = true
|
||||
autoSaveEnabled = true,
|
||||
onToggleAutoSave
|
||||
}: StatusBarProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const loadingStates = useEditorStore(s => s.loadingStates)
|
||||
@@ -58,15 +60,16 @@ export const StatusBar = React.memo(function StatusBar({
|
||||
<span className="status-divider">|</span>
|
||||
</>
|
||||
)}
|
||||
{activeTab?.filePath && autoSaveEnabled && (
|
||||
{activeTab?.filePath && onToggleAutoSave !== undefined && (
|
||||
<>
|
||||
<span
|
||||
<button
|
||||
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
|
||||
title={isAutoSaving ? '正在自动保存...' : '自动保存已开启'}
|
||||
aria-label={isAutoSaving ? '正在自动保存' : '自动保存'}
|
||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||
onClick={onToggleAutoSave}
|
||||
>
|
||||
{isAutoSaving ? '保存中...' : '自动'}
|
||||
</span>
|
||||
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
|
||||
</button>
|
||||
<span className="status-divider">|</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
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()
|
||||
|
||||
// 滚动到活动标签
|
||||
@@ -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() {
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
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"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
data-tab-id={tab.id}
|
||||
draggable
|
||||
onClick={() => 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}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{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,10 +15,8 @@ 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]
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (!dirPath) return
|
||||
setRootPath(dirPath)
|
||||
expandDirs([dirPath])
|
||||
setLoading('dir-load', true)
|
||||
@@ -31,7 +29,6 @@ export function useFolderOperations() {
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ import { useEffect, useCallback, useRef } from 'react'
|
||||
*/
|
||||
export function useUnsavedWarning(
|
||||
hasUnsaved: () => boolean,
|
||||
confirmFn?: (message: string) => Promise<boolean>
|
||||
confirmFn?: (message: string) => Promise<boolean>,
|
||||
// D5: 关闭前回调(flush 待保存数据)
|
||||
onBeforeForceClose?: () => Promise<void>
|
||||
) {
|
||||
const confirmFnRef = useRef(confirmFn)
|
||||
confirmFnRef.current = confirmFn
|
||||
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
|
||||
onBeforeForceCloseRef.current = onBeforeForceClose
|
||||
|
||||
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
||||
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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<TabState>((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<TabState>((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<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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@@ -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<void>
|
||||
openExternal: (url: string) => void
|
||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user