From 43b077884933241426bc730eea57781a5932691c Mon Sep 17 00:00:00 2001
From: thzxx <1440196015@qq.com>
Date: Wed, 22 Jul 2026 15:55:45 +0800
Subject: [PATCH] =?UTF-8?q?release:=20v0.3.13=20=E2=80=94=20MeToast=20?=
=?UTF-8?q?=E9=9B=86=E6=88=90=E4=B8=8E=E5=85=A8=E9=9D=A2=E8=B4=A8=E9=87=8F?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 集成 MetonaToast v2.0.0 替换自研 Toast 组件(右上角、进度条、自动关闭、主题联动)
- 修复 sidebar resize 未持久化到 IndexedDB
- 修复 Ctrl+Tab MRU 切换逻辑与文档不一致
- 完善 WYSIWYG 模式标签切换选区恢复
- 清理死代码(useActiveHeading observerRef、旧 Toast 样式、重复滚动条样式、SearchReplace 重复检查)
- 同步 DESIGN.md / docs 文档(CodeMirror→Milkdown、stores 计数、editorStore 字段)
- 新增 .npmrc.example 模板
- 更新关于对话框与文档远程地址为 Gitea
- 版本号 0.3.12 → 0.3.13
---
.npmrc.example | 15 ++
CONTRIBUTING.md | 2 +-
DESIGN.md | 40 +++--
README.md | 6 +-
docs/optimization-delivery-report.json | 9 +-
docs/optimization-requirements.json | 8 +-
package.json | 3 +-
src/renderer/App.tsx | 10 +-
.../components/AboutDialog/AboutDialog.tsx | 6 +-
src/renderer/components/Editor/Editor.tsx | 4 +-
src/renderer/components/Editor/useMilkdown.ts | 31 +++-
.../SearchReplace/SearchReplace.tsx | 1 -
src/renderer/components/Toast/Toast.tsx | 103 -----------
src/renderer/components/Toast/index.ts | 2 -
src/renderer/hooks/useActiveHeading.ts | 5 +-
src/renderer/hooks/useDragDrop.ts | 5 +-
src/renderer/hooks/useFileOperations.ts | 10 +-
src/renderer/hooks/useKeyboard.ts | 24 +--
src/renderer/hooks/useSidebarResize.ts | 14 +-
src/renderer/hooks/useTheme.ts | 2 +
src/renderer/hooks/useToast.ts | 45 -----
src/renderer/lib/toast.ts | 41 +++++
src/renderer/styles/global.css | 169 ------------------
src/shared/constants.ts | 2 +-
24 files changed, 170 insertions(+), 387 deletions(-)
create mode 100644 .npmrc.example
delete mode 100644 src/renderer/components/Toast/Toast.tsx
delete mode 100644 src/renderer/components/Toast/index.ts
delete mode 100644 src/renderer/hooks/useToast.ts
create mode 100644 src/renderer/lib/toast.ts
diff --git a/.npmrc.example b/.npmrc.example
new file mode 100644
index 0000000..5ebd230
--- /dev/null
+++ b/.npmrc.example
@@ -0,0 +1,15 @@
+# MarkLite npm 配置模板
+# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置)
+#
+# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载:
+# registry=https://registry.npmmirror.com
+# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
+# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
+#
+# 配置说明:
+# registry — npm 包下载源
+# ELECTRON_MIRROR — Electron 二进制文件下载源
+# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源
+#
+# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。
+# 详见 DEVSETUP.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 611b445..b1c3515 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,7 +11,7 @@
2. **克隆并安装**
```bash
- git clone https://gitee.com/thzxx/MarkLite.git
+ git clone https://git.metona.cn/MetonaTeam/MarkLite.git
cd MarkLite
npm install
```
diff --git a/DESIGN.md b/DESIGN.md
index 44e2d5d..4bad232 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,4 +1,4 @@
-# MarkLite v0.3.12 — 架构设计文档
+# MarkLite v0.3.13 — 架构设计文档
## 1. 项目概述
@@ -44,7 +44,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
┌───────────────────────▼──────────────────────────────────────┐
│ Preload Script (src/preload/) 1 文件 │
│ index.ts contextBridge 类型安全暴露 │
-│ electronAPI 18 个方法/事件的类型安全接口 │
+│ electronAPI 22 个方法/事件的类型安全接口 │
└───────────────────────┬──────────────────────────────────────┘
│
┌───────────────────────▼──────────────────────────────────────┐
@@ -57,7 +57,8 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
│ SearchReplace · DropOverlay · ErrorBoundary│
│ AboutDialog · LoadingSpinner · Icons │
│ │
-│ stores/ (3) tabStore · editorStore · sidebarStore │
+│ stores/ (5) tabStore · editorStore · sidebarStore │
+│ statusBarStore · autoSaveStore │
│ hooks/ (19) useTheme · useSettings · useSettingsInit │
│ useKeyboard · useDragDrop · useFileWatch │
│ useAutoSave · useIpcListeners ... │
@@ -83,7 +84,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|------|------|------|
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
-| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 22 个类型安全方法/事件 |
+| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
@@ -101,12 +102,18 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
│ │ │ │ (CM6) │ │
├─────────┴──────────┴──────────┴──────────┴──────────────┤
│ Zustand Stores │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ tabStore │ │editorStore│ │sidebarStore│ │
-│ │ - tabs │ │- viewMode│ │- tree │ │
-│ │- activeId│ │- darkMode│ │- expanded │ │
-│ │ - mru │ │ │ │- rootPath │ │
-│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
+│ │ tabStore │ │editorStore│ │sidebarStore│ │statusBarStore│ │
+│ │ - tabs │ │- viewMode│ │- tree │ │- items │ │
+│ │- activeId│ │- darkMode│ │- expanded │ │ │ │
+│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
+│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
+│ │ │ │ │ │
+│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
+│ │ ┌──────────────┐ │ │
+│ │ │autoSaveStore │ │ │
+│ │ │- isAutoSaving│ │ │
+│ │ └──────────────┘ │ │
│ │ │ │ │
│ ┌────▼────────────▼────────────▼────────────────────┐ │
│ │ IndexedDB (Dexie.js) │ │
@@ -139,8 +146,10 @@ interface TabState {
```typescript
interface EditorState {
- viewMode: 'editor' | 'preview' // 视图模式
+ viewMode: 'editor' | 'preview' | 'source' // 视图模式
darkMode: boolean // 暗色主题
+ externallyModified: { filePath: string } | null // 外部修改检测状态
+ loadingStates: Record // 全局加载状态
}
```
@@ -151,7 +160,7 @@ interface SidebarState {
isVisible: boolean // 是否显示
rootPath: string | null // 当前打开的文件夹路径
tree: FileNode[] // 目录树数据
- expandedDirs: Set // 已展开的目录集合
+ expandedDirs: string[] // 已展开的目录集合
sidebarWidth: number // 侧边栏宽度 (180~500)
}
```
@@ -221,6 +230,11 @@ db.version(1).stores({
两种模式共享同一 tabStore 数据源,可随时切换。
+### 6.1.1 主题切换
+
+Milkdown 编辑器通过 CSS class(`milkdown-dark`)切换暗色主题,无需销毁重建 EditorView,
+切换标签时保持光标位置和选区。
+
### 6.2 插件体系
| 插件 | 说明 |
@@ -351,7 +365,7 @@ npm run build:portable # 便携版(免安装)
| zustand | ^5.0 | 状态管理 |
| dexie | ^4.0 | IndexedDB 封装 |
| nanoid | ^5.0 | 唯一 ID 生成 |
-| @codemirror/* | ^6.x | 代码编辑器 |
+| @milkdown/* | 7.21.1 | WYSIWYG Markdown 编辑器(基于 ProseMirror) |
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
| rehype-highlight | ^7.0 | 代码语法高亮 |
diff --git a/README.md b/README.md
index 8c25162..c64f48a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -55,7 +55,7 @@
```bash
# 克隆仓库
-git clone https://gitee.com/thzxx/MarkLite.git
+git clone https://git.metona.cn/MetonaTeam/MarkLite.git
cd MarkLite
# 安装依赖
@@ -277,7 +277,7 @@ npm run prepare
## 📄 许可证
-[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx)
+[MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
---
diff --git a/docs/optimization-delivery-report.json b/docs/optimization-delivery-report.json
index aabe9f6..1fd7f36 100644
--- a/docs/optimization-delivery-report.json
+++ b/docs/optimization-delivery-report.json
@@ -127,10 +127,11 @@
},
"codemirrorThemeCompartment": {
"id": "PF-04",
- "mechanism": "CodeMirror Compartment.reconfigure()",
- "implementation": "src/renderer/components/Editor/useCodeMirror.ts",
- "description": "创建 themeCompartment,darkMode 变化时通过 reconfigure() 交换主题扩展,不重建 EditorView",
- "expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置"
+ "mechanism": "Milkdown CSS class 切换(milkdown-dark)",
+ "implementation": "src/renderer/components/Editor/useMilkdown.ts",
+ "description": "darkMode 变化时切换 milkdown-dark CSS class,无需销毁重建 EditorView",
+ "expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置",
+ "note": "项目已从 CodeMirror 6 迁移至 Milkdown v7 WYSIWYG 编辑器,原 Compartment 方案不再适用"
},
"previewDebounce": {
"id": "PF-05",
diff --git a/docs/optimization-requirements.json b/docs/optimization-requirements.json
index 7fab29f..c8e6e6e 100644
--- a/docs/optimization-requirements.json
+++ b/docs/optimization-requirements.json
@@ -8,7 +8,7 @@
"typescript": "5.6.3",
"vite": "electron-vite 3.0.0",
"stateManagement": "zustand 5.0.6",
- "editor": "CodeMirror 6",
+ "editor": "Milkdown 7.21.1 (WYSIWYG) + textarea (source mode)",
"database": "Dexie (IndexedDB)"
},
"currentStatus": {
@@ -46,7 +46,11 @@
"effort": "small",
"details": {
"configFormat": "ESM flat config (eslint.config.js)",
- "plugins": ["@typescript-eslint/eslint-plugin", "eslint-plugin-react-hooks", "eslint-plugin-react-refresh"],
+ "plugins": [
+ "@typescript-eslint/eslint-plugin",
+ "eslint-plugin-react-hooks",
+ "eslint-plugin-react-refresh"
+ ],
"rules": [
"typescript-eslint/recommended",
"react-hooks/recommended",
diff --git a/package.json b/package.json
index 77edb87..cd269f7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "marklite",
- "version": "0.3.12",
+ "version": "0.3.13",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
@@ -41,6 +41,7 @@
"@milkdown/react": "7.21.1",
"@milkdown/utils": "7.21.1",
"dexie": "^4.0.11",
+ "metona-toast": "file:../MetonaToast",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index cba24b2..2ab3f0f 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -12,7 +12,6 @@ import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners'
-import { useToast } from './hooks/useToast'
import { useConfirm } from './hooks/useConfirm'
import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
import { useStatusBarItem } from './hooks/useStatusBarItem'
@@ -26,7 +25,6 @@ import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
-import { ToastContainer } from './components/Toast/Toast'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary'
@@ -64,16 +62,15 @@ export function App() {
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode } = useSettings()
- const { toasts, showToast, dismissToast, cleanup } = useToast()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit()
- useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
+ useEffect(() => { loadFromDB() }, [loadFromDB])
- const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
- useDragDrop(showToast)
+ const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
+ useDragDrop()
useFileWatch()
useAutoSave()
@@ -152,7 +149,6 @@ export function App() {
-
{showAbout && }
diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx
index 93a212d..a2933b6 100644
--- a/src/renderer/components/AboutDialog/AboutDialog.tsx
+++ b/src/renderer/components/AboutDialog/AboutDialog.tsx
@@ -10,9 +10,9 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
const handleLinkClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (window.electronAPI?.openExternal) {
- window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
+ window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
} else {
- window.open('https://gitee.com/thzxx/MarkLite', '_blank')
+ window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank')
}
}
@@ -41,7 +41,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
- gitee.com/thzxx/MarkLite
+ git.metona.cn/MetonaTeam/MarkLite
基于 Electron + React + TypeScript 构建
© 2026 thzxx
diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx
index 8063b9f..8e70337 100644
--- a/src/renderer/components/Editor/Editor.tsx
+++ b/src/renderer/components/Editor/Editor.tsx
@@ -53,7 +53,7 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
setContent(activeTab.content)
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
- setSelection()
+ setSelection(activeTab.selectionStart, activeTab.selectionEnd)
})
})
}
@@ -79,7 +79,7 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
}
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [activeTabId])
+ }, [activeTabId])
// Register getView for OutlinePanel navigation
useEffect(() => {
diff --git a/src/renderer/components/Editor/useMilkdown.ts b/src/renderer/components/Editor/useMilkdown.ts
index f1768ce..646586b 100644
--- a/src/renderer/components/Editor/useMilkdown.ts
+++ b/src/renderer/components/Editor/useMilkdown.ts
@@ -286,13 +286,30 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
}
}, [])
- const setSelection = useCallback(() => {
- // ProseMirror selection setting requires the view instance
- // which we access lazily; for now we focus the editor
- const container = containerRef.current
- if (!container) return
- const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
- pmEditor?.focus()
+ const setSelection = useCallback((from?: number, to?: number) => {
+ const editor = editorRef.current
+ if (!editor) return
+ try {
+ editor.action((ctx) => {
+ const view = ctx.get(editorViewCtx)
+ if (!view) return
+ const docSize = view.state.doc.content.size
+ // 恢复保存的选区,越界时夹紧到文档末尾
+ const safeFrom = Math.min(from ?? 0, docSize)
+ const safeTo = Math.min(to ?? safeFrom, docSize)
+ const tr = view.state.tr.setSelection(
+ TextSelection.create(view.state.doc, safeFrom, safeTo)
+ )
+ view.dispatch(tr)
+ view.focus()
+ })
+ } catch {
+ // 编辑器未就绪或选区无效,回退到仅 focus
+ const container = containerRef.current
+ if (!container) return
+ const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
+ pmEditor?.focus()
+ }
}, [])
// Get ProseMirror EditorView instance
diff --git a/src/renderer/components/SearchReplace/SearchReplace.tsx b/src/renderer/components/SearchReplace/SearchReplace.tsx
index 794882d..0a83a02 100644
--- a/src/renderer/components/SearchReplace/SearchReplace.tsx
+++ b/src/renderer/components/SearchReplace/SearchReplace.tsx
@@ -254,7 +254,6 @@ export const SearchReplace = memo(function SearchReplace({
// 重新搜索以获取匹配的最新位置
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
if (freshMatches.length === 0) return
- if (freshMatches.length === 0) return
// 从后往前替换以保持位置正确
const tr = view.state.tr
diff --git a/src/renderer/components/Toast/Toast.tsx b/src/renderer/components/Toast/Toast.tsx
deleted file mode 100644
index 76bd9c1..0000000
--- a/src/renderer/components/Toast/Toast.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react'
-
-/** Toast 类型 */
-export type ToastType = 'success' | 'error' | 'warning' | 'info'
-
-/** 单条 Toast 数据 */
-export interface ToastItem {
- id: string
- message: string
- type: ToastType
- duration: number
-}
-
-/** Toast 容器组件属性 */
-interface ToastContainerProps {
- toasts: ToastItem[]
- onDismiss: (id: string) => void
-}
-
-/** 单条 Toast 组件 */
-const SingleToast = React.memo(function SingleToast({
- toast,
- onDismiss
-}: {
- toast: ToastItem
- onDismiss: (id: string) => void
-}) {
- const [visible, setVisible] = useState(false)
- const timerRef = useRef
| null>(null)
-
- useEffect(() => {
- // 进入动画
- requestAnimationFrame(() => setVisible(true))
-
- // 自动消失
- if (toast.duration > 0) {
- timerRef.current = setTimeout(() => {
- setVisible(false)
- // 等待退出动画完成后再移除
- setTimeout(() => onDismiss(toast.id), 300)
- }, toast.duration)
- }
-
- return () => {
- if (timerRef.current) clearTimeout(timerRef.current)
- }
- }, [toast.id, toast.duration, onDismiss])
-
- const handleClose = useCallback(() => {
- if (timerRef.current) clearTimeout(timerRef.current)
- setVisible(false)
- setTimeout(() => onDismiss(toast.id), 300)
- }, [toast.id, onDismiss])
-
- const typeIcons: Record = {
- success: '✓',
- error: '✕',
- warning: '⚠',
- info: 'ℹ'
- }
-
- return (
-
-
- {typeIcons[toast.type]}
-
- {toast.message}
-
-
- )
-})
-
-/**
- * UX-05: Toast 容器组件
- * 支持多条堆叠、类型区分、关闭按钮和自动消失
- */
-export const ToastContainer = React.memo(function ToastContainer({
- toasts,
- onDismiss
-}: ToastContainerProps) {
- if (toasts.length === 0) return null
-
- return (
-
- {toasts.map(toast => (
-
- ))}
-
- )
-})
-
-ToastContainer.displayName = 'ToastContainer'
diff --git a/src/renderer/components/Toast/index.ts b/src/renderer/components/Toast/index.ts
deleted file mode 100644
index 5296b25..0000000
--- a/src/renderer/components/Toast/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { ToastContainer } from './Toast'
-export type { ToastType, ToastItem } from './Toast'
diff --git a/src/renderer/hooks/useActiveHeading.ts b/src/renderer/hooks/useActiveHeading.ts
index 727bcea..78447bb 100644
--- a/src/renderer/hooks/useActiveHeading.ts
+++ b/src/renderer/hooks/useActiveHeading.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, useCallback } from 'react'
+import { useEffect, useState, useCallback } from 'react'
/**
* D4: 基于视口的活跃标题追踪 hook
@@ -16,7 +16,6 @@ export function useActiveHeading(
headings: { level: number; text: string }[]
): number | null {
const [activeIndex, setActiveIndex] = useState(null)
- const observerRef = useRef(null)
const handleScroll = useCallback(() => {
const container = containerRef.current
@@ -71,10 +70,8 @@ export function useActiveHeading(
// 初始计算
handleScroll()
- const observer = observerRef.current
return () => {
container.removeEventListener('scroll', handleScroll)
- observer?.disconnect()
}
}, [containerRef, headings, handleScroll])
diff --git a/src/renderer/hooks/useDragDrop.ts b/src/renderer/hooks/useDragDrop.ts
index cb8fb7b..af1b950 100644
--- a/src/renderer/hooks/useDragDrop.ts
+++ b/src/renderer/hooks/useDragDrop.ts
@@ -3,8 +3,9 @@ import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants'
import { logError } from '../lib/errorHandler'
+import { showToast } from '../lib/toast'
-export function useDragDrop(showToast: (msg: string) => void) {
+export function useDragDrop() {
const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(async (e: DragEvent) => {
@@ -50,7 +51,7 @@ export function useDragDrop(showToast: (msg: string) => void) {
if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
}
- }, [createTab, showToast])
+ }, [createTab])
useEffect(() => {
const prevent = (e: DragEvent) => {
diff --git a/src/renderer/hooks/useFileOperations.ts b/src/renderer/hooks/useFileOperations.ts
index c1daa27..1473327 100644
--- a/src/renderer/hooks/useFileOperations.ts
+++ b/src/renderer/hooks/useFileOperations.ts
@@ -3,13 +3,13 @@ import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler'
-import type { ToastType } from '../components/Toast/Toast'
+import { showToast } from '../lib/toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
*/
-export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
+export function useFileOperations() {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
@@ -30,7 +30,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
} finally {
setLoading('file-open', false)
}
- }, [createTab, showToast, setLoading])
+ }, [createTab, setLoading])
const handleSave = useCallback(async (): Promise => {
try {
@@ -48,7 +48,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
logError('保存文件失败', error)
showToast('保存失败', 'error')
}
- }, [getActiveTab, showToast])
+ }, [getActiveTab])
const handleSaveAs = useCallback(async (): Promise => {
try {
@@ -63,7 +63,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
logError('另存为失败', error)
showToast('另存为失败', 'error')
}
- }, [getActiveTab, showToast])
+ }, [getActiveTab])
const handleOpenRecent = useCallback(async (filePath: string): Promise => {
if (!window.electronAPI) return
diff --git a/src/renderer/hooks/useKeyboard.ts b/src/renderer/hooks/useKeyboard.ts
index cde48fe..518b79c 100644
--- a/src/renderer/hooks/useKeyboard.ts
+++ b/src/renderer/hooks/useKeyboard.ts
@@ -23,23 +23,25 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
return
}
- // Ctrl+Tab / Ctrl+Shift+Tab
+ // Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
if (isCtrl && e.key === 'Tab') {
e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) {
- if (e.shiftKey) {
- if (mruStack.length > 0) {
- const targetId = mruStack[0]
- if (tabs.find(t => t.id === targetId)) {
- tabState.switchToTab(targetId)
- }
+ // mruStack[0] 是最近一个被切走的标签(即上一个活动标签)
+ if (mruStack.length > 0) {
+ const targetId = mruStack[0]
+ if (tabs.find(t => t.id === targetId)) {
+ tabState.switchToTab(targetId)
+ return
}
- } else {
- const idx = tabs.findIndex(t => t.id === activeTabId)
- const next = (idx + 1) % tabs.length
- tabState.switchToTab(tabs[next].id)
}
+ // MRU 栈为空或不合法时回退到顺序切换
+ const idx = tabs.findIndex(t => t.id === activeTabId)
+ const next = e.shiftKey
+ ? (idx - 1 + tabs.length) % tabs.length
+ : (idx + 1) % tabs.length
+ tabState.switchToTab(tabs[next].id)
}
return
}
diff --git a/src/renderer/hooks/useSidebarResize.ts b/src/renderer/hooks/useSidebarResize.ts
index 9cf6f7d..2ffeb62 100644
--- a/src/renderer/hooks/useSidebarResize.ts
+++ b/src/renderer/hooks/useSidebarResize.ts
@@ -1,7 +1,11 @@
import { useState, useRef, useEffect } from 'react'
+import { useSidebarStore } from '../stores/sidebarStore'
/**
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
+ *
+ * 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
+ * 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
*/
export function useSidebarResize() {
const [isResizing, setIsResizing] = useState(false)
@@ -17,7 +21,15 @@ export function useSidebarResize() {
}
}
- const handleMouseUp = (): void => setIsResizing(false)
+ const handleMouseUp = (): void => {
+ // 拖拽结束时将最终宽度持久化到 store(→ IndexedDB)
+ if (sidebarRef.current) {
+ const width = sidebarRef.current.offsetWidth
+ const clamped = Math.max(180, Math.min(500, width))
+ useSidebarStore.getState().setSidebarWidth(clamped)
+ }
+ setIsResizing(false)
+ }
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts
index 5255a9b..e0c0cb5 100644
--- a/src/renderer/hooks/useTheme.ts
+++ b/src/renderer/hooks/useTheme.ts
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
+import { syncToastTheme } from '../lib/toast'
/**
* AR-04: 主题 hook
@@ -15,6 +16,7 @@ export function useTheme() {
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode })
+ syncToastTheme(darkMode)
}, [darkMode])
return { darkMode, toggleDarkMode }
diff --git a/src/renderer/hooks/useToast.ts b/src/renderer/hooks/useToast.ts
deleted file mode 100644
index ee2bf78..0000000
--- a/src/renderer/hooks/useToast.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useState, useCallback } from 'react'
-import type { ToastItem, ToastType } from '../components/Toast/Toast'
-
-/** 默认自动消失时间 (ms) */
-const DEFAULT_DURATION = 3000
-
-/** 最大同时显示数量 */
-const MAX_TOASTS = 5
-
-// Module-level counter survives component remounts
-let toastCounter = 0
-
-/**
- * UX-05: 升级版 Toast 状态管理 hook
- * 支持多条堆叠、类型区分、自动消失
- */
-export function useToast() {
- const [toasts, setToasts] = useState([])
-
- const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
- const id = `toast-${++toastCounter}`
- const newToast: ToastItem = { id, message: msg, type, duration }
-
- setToasts(prev => {
- const next = [...prev, newToast]
- // 超过最大数量时移除最旧的
- if (next.length > MAX_TOASTS) {
- return next.slice(next.length - MAX_TOASTS)
- }
- return next
- })
-
- return id
- }, [])
-
- const dismissToast = useCallback((id: string) => {
- setToasts(prev => prev.filter(t => t.id !== id))
- }, [])
-
- const cleanup = useCallback(() => {
- setToasts([])
- }, [])
-
- return { toasts, showToast, dismissToast, cleanup }
-}
diff --git a/src/renderer/lib/toast.ts b/src/renderer/lib/toast.ts
new file mode 100644
index 0000000..8d69ddd
--- /dev/null
+++ b/src/renderer/lib/toast.ts
@@ -0,0 +1,41 @@
+import MeToast from 'metona-toast'
+
+/**
+ * MeToast 全局配置
+ *
+ * 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
+ * MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
+ */
+MeToast.configure({
+ position: 'top-right',
+ duration: 3000,
+ max: 5,
+ theme: 'auto',
+ animation: 'slide',
+ pauseOnHover: true,
+ closeOnClick: true,
+ showProgress: true,
+ draggable: true,
+ locale: 'zh-CN',
+})
+
+/** 与原 showToast 保持兼容的类型子集 */
+export type ToastType = 'success' | 'error' | 'warning' | 'info'
+
+/**
+ * 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
+ *
+ * @param msg 消息内容
+ * @param type 通知类型(默认 info)
+ * @param duration 显示时长(ms),省略则用全局默认值
+ */
+export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
+ MeToast[type](msg, duration !== undefined ? { duration } : undefined)
+}
+
+/** 同步 MeToast 主题到当前暗色模式 */
+export function syncToastTheme(darkMode: boolean): void {
+ MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
+}
+
+export { MeToast }
diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css
index 349bf27..d5e5fe9 100644
--- a/src/renderer/styles/global.css
+++ b/src/renderer/styles/global.css
@@ -388,14 +388,6 @@ body {
background: var(--text-secondary);
}
-#tab-list::-webkit-scrollbar-track {
- background: var(--bg-tertiary);
-}
-
-#tab-list::-webkit-scrollbar-thumb:hover {
- background: var(--text-tertiary);
-}
-
#tab-list::-webkit-scrollbar-track {
background: transparent;
}
@@ -1006,35 +998,6 @@ body {
display: none;
}
-/* Toast */
-#toast-notification {
- position: fixed;
- bottom: 40px;
- left: 50%;
- transform: translateX(-50%) translateY(20px);
- background: var(--text);
- color: var(--bg);
- padding: 8px 20px;
- border-radius: 6px;
- font-size: 13px;
- font-family: var(--font-ui);
- box-shadow: var(--shadow-lg);
- opacity: 0;
- pointer-events: none;
- transition:
- opacity 0.3s ease,
- transform 0.3s ease;
- z-index: 9999;
- max-width: 480px;
- text-align: center;
-}
-
-#toast-notification.show {
- opacity: 1;
- transform: translateX(-50%) translateY(0);
- pointer-events: auto;
-}
-
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -1371,138 +1334,6 @@ body {
animation: spin 0.8s linear infinite;
}
-/* ===== UX-05: Toast Container (升级版) ===== */
-.toast-container {
- position: fixed;
- bottom: 44px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- flex-direction: column-reverse;
- gap: 8px;
- z-index: 9999;
- pointer-events: none;
- max-width: 480px;
- width: 100%;
- padding: 0 16px;
-}
-
-.toast-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 16px;
- border-radius: 8px;
- font-size: 13px;
- font-family: var(--font-ui);
- box-shadow: var(--shadow-lg);
- opacity: 0;
- transform: translateY(10px) scale(0.95);
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
- pointer-events: auto;
- max-width: 100%;
-}
-
-.toast-item.toast-visible {
- opacity: 1;
- transform: translateY(0) scale(1);
-}
-
-/* Toast 类型样式 */
-.toast-success {
- background: #e6f4ea;
- color: #137333;
- border: 1px solid #a8dab5;
-}
-
-.toast-error {
- background: #fce8e6;
- color: #c5221f;
- border: 1px solid #f28b82;
-}
-
-.toast-warning {
- background: #fef7e0;
- color: #945700;
- border: 1px solid #fdd663;
-}
-
-.toast-info {
- background: var(--text);
- color: var(--bg);
- border: 1px solid transparent;
-}
-
-:root.dark .toast-success {
- background: #0d3a1a;
- color: #81c995;
- border-color: #1e5a2e;
-}
-
-:root.dark .toast-error {
- background: #3c1214;
- color: #f28b82;
- border-color: #5c1a1a;
-}
-
-:root.dark .toast-warning {
- background: #3a3000;
- color: #fdd663;
- border-color: #5a4a00;
-}
-
-:root.dark .toast-info {
- background: var(--bg-secondary);
- color: var(--text);
- border-color: var(--border);
-}
-
-.toast-icon {
- flex-shrink: 0;
- width: 18px;
- height: 18px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 14px;
- font-weight: 700;
-}
-
-.toast-message {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.toast-close {
- flex-shrink: 0;
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- background: transparent;
- color: inherit;
- opacity: 0.6;
- cursor: pointer;
- font-size: 12px;
- border-radius: 4px;
- padding: 0;
- transition: opacity 0.15s ease;
-}
-
-.toast-close:hover {
- opacity: 1;
-}
-
-.toast-close:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 1px;
-}
-
/* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 626e8da..1c38fca 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
-export const APP_VERSION = 'v0.3.12'
+export const APP_VERSION = 'v0.3.13'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([