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 @@ TypeScript React License - Version + Version

@@ -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 ( -
- - {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([