release: v0.3.13 — MeToast 集成与全面质量修复
- 集成 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
This commit is contained in:
@@ -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
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
2. **克隆并安装**
|
2. **克隆并安装**
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitee.com/thzxx/MarkLite.git
|
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||||
cd MarkLite
|
cd MarkLite
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# MarkLite v0.3.12 — 架构设计文档
|
# MarkLite v0.3.13 — 架构设计文档
|
||||||
|
|
||||||
## 1. 项目概述
|
## 1. 项目概述
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
┌───────────────────────▼──────────────────────────────────────┐
|
┌───────────────────────▼──────────────────────────────────────┐
|
||||||
│ Preload Script (src/preload/) 1 文件 │
|
│ Preload Script (src/preload/) 1 文件 │
|
||||||
│ index.ts contextBridge 类型安全暴露 │
|
│ index.ts contextBridge 类型安全暴露 │
|
||||||
│ electronAPI 18 个方法/事件的类型安全接口 │
|
│ electronAPI 22 个方法/事件的类型安全接口 │
|
||||||
└───────────────────────┬──────────────────────────────────────┘
|
└───────────────────────┬──────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────────────▼──────────────────────────────────────┐
|
┌───────────────────────▼──────────────────────────────────────┐
|
||||||
@@ -57,7 +57,8 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
│ SearchReplace · DropOverlay · ErrorBoundary│
|
│ SearchReplace · DropOverlay · ErrorBoundary│
|
||||||
│ AboutDialog · LoadingSpinner · Icons │
|
│ AboutDialog · LoadingSpinner · Icons │
|
||||||
│ │
|
│ │
|
||||||
│ stores/ (3) tabStore · editorStore · sidebarStore │
|
│ stores/ (5) tabStore · editorStore · sidebarStore │
|
||||||
|
│ statusBarStore · autoSaveStore │
|
||||||
│ hooks/ (19) useTheme · useSettings · useSettingsInit │
|
│ hooks/ (19) useTheme · useSettings · useSettingsInit │
|
||||||
│ useKeyboard · useDragDrop · useFileWatch │
|
│ useKeyboard · useDragDrop · useFileWatch │
|
||||||
│ useAutoSave · useIpcListeners ... │
|
│ useAutoSave · useIpcListeners ... │
|
||||||
@@ -83,7 +84,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
||||||
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
||||||
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 22 个类型安全方法/事件 |
|
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
|
||||||
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
||||||
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
||||||
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
||||||
@@ -101,12 +102,18 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
│ │ │ │ (CM6) │ │
|
│ │ │ │ (CM6) │ │
|
||||||
├─────────┴──────────┴──────────┴──────────┴──────────────┤
|
├─────────┴──────────┴──────────┴──────────┴──────────────┤
|
||||||
│ Zustand Stores │
|
│ Zustand Stores │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||||
│ │ tabStore │ │editorStore│ │sidebarStore│ │
|
│ │ tabStore │ │editorStore│ │sidebarStore│ │statusBarStore│ │
|
||||||
│ │ - tabs │ │- viewMode│ │- tree │ │
|
│ │ - tabs │ │- viewMode│ │- tree │ │- items │ │
|
||||||
│ │- activeId│ │- darkMode│ │- expanded │ │
|
│ │- activeId│ │- darkMode│ │- expanded │ │ │ │
|
||||||
│ │ - mru │ │ │ │- rootPath │ │
|
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
|
||||||
|
│ │ ┌──────────────┐ │ │
|
||||||
|
│ │ │autoSaveStore │ │ │
|
||||||
|
│ │ │- isAutoSaving│ │ │
|
||||||
|
│ │ └──────────────┘ │ │
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ ┌────▼────────────▼────────────▼────────────────────┐ │
|
│ ┌────▼────────────▼────────────▼────────────────────┐ │
|
||||||
│ │ IndexedDB (Dexie.js) │ │
|
│ │ IndexedDB (Dexie.js) │ │
|
||||||
@@ -139,8 +146,10 @@ interface TabState {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewMode: 'editor' | 'preview' // 视图模式
|
viewMode: 'editor' | 'preview' | 'source' // 视图模式
|
||||||
darkMode: boolean // 暗色主题
|
darkMode: boolean // 暗色主题
|
||||||
|
externallyModified: { filePath: string } | null // 外部修改检测状态
|
||||||
|
loadingStates: Record<string, boolean> // 全局加载状态
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -151,7 +160,7 @@ interface SidebarState {
|
|||||||
isVisible: boolean // 是否显示
|
isVisible: boolean // 是否显示
|
||||||
rootPath: string | null // 当前打开的文件夹路径
|
rootPath: string | null // 当前打开的文件夹路径
|
||||||
tree: FileNode[] // 目录树数据
|
tree: FileNode[] // 目录树数据
|
||||||
expandedDirs: Set<string> // 已展开的目录集合
|
expandedDirs: string[] // 已展开的目录集合
|
||||||
sidebarWidth: number // 侧边栏宽度 (180~500)
|
sidebarWidth: number // 侧边栏宽度 (180~500)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -221,6 +230,11 @@ db.version(1).stores({
|
|||||||
|
|
||||||
两种模式共享同一 tabStore 数据源,可随时切换。
|
两种模式共享同一 tabStore 数据源,可随时切换。
|
||||||
|
|
||||||
|
### 6.1.1 主题切换
|
||||||
|
|
||||||
|
Milkdown 编辑器通过 CSS class(`milkdown-dark`)切换暗色主题,无需销毁重建 EditorView,
|
||||||
|
切换标签时保持光标位置和选区。
|
||||||
|
|
||||||
### 6.2 插件体系
|
### 6.2 插件体系
|
||||||
|
|
||||||
| 插件 | 说明 |
|
| 插件 | 说明 |
|
||||||
@@ -351,7 +365,7 @@ npm run build:portable # 便携版(免安装)
|
|||||||
| zustand | ^5.0 | 状态管理 |
|
| zustand | ^5.0 | 状态管理 |
|
||||||
| dexie | ^4.0 | IndexedDB 封装 |
|
| dexie | ^4.0 | IndexedDB 封装 |
|
||||||
| nanoid | ^5.0 | 唯一 ID 生成 |
|
| nanoid | ^5.0 | 唯一 ID 生成 |
|
||||||
| @codemirror/* | ^6.x | 代码编辑器 |
|
| @milkdown/* | 7.21.1 | WYSIWYG Markdown 编辑器(基于 ProseMirror) |
|
||||||
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
||||||
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||||
<img src="https://img.shields.io/badge/Version-v0.3.12-orange?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/Version-v0.3.13-orange?style=flat-square" alt="Version">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
# 克隆仓库
|
||||||
git clone https://gitee.com/thzxx/MarkLite.git
|
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||||
cd MarkLite
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -127,10 +127,11 @@
|
|||||||
},
|
},
|
||||||
"codemirrorThemeCompartment": {
|
"codemirrorThemeCompartment": {
|
||||||
"id": "PF-04",
|
"id": "PF-04",
|
||||||
"mechanism": "CodeMirror Compartment.reconfigure()",
|
"mechanism": "Milkdown CSS class 切换(milkdown-dark)",
|
||||||
"implementation": "src/renderer/components/Editor/useCodeMirror.ts",
|
"implementation": "src/renderer/components/Editor/useMilkdown.ts",
|
||||||
"description": "创建 themeCompartment,darkMode 变化时通过 reconfigure() 交换主题扩展,不重建 EditorView",
|
"description": "darkMode 变化时切换 milkdown-dark CSS class,无需销毁重建 EditorView",
|
||||||
"expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置"
|
"expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置",
|
||||||
|
"note": "项目已从 CodeMirror 6 迁移至 Milkdown v7 WYSIWYG 编辑器,原 Compartment 方案不再适用"
|
||||||
},
|
},
|
||||||
"previewDebounce": {
|
"previewDebounce": {
|
||||||
"id": "PF-05",
|
"id": "PF-05",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "electron-vite 3.0.0",
|
"vite": "electron-vite 3.0.0",
|
||||||
"stateManagement": "zustand 5.0.6",
|
"stateManagement": "zustand 5.0.6",
|
||||||
"editor": "CodeMirror 6",
|
"editor": "Milkdown 7.21.1 (WYSIWYG) + textarea (source mode)",
|
||||||
"database": "Dexie (IndexedDB)"
|
"database": "Dexie (IndexedDB)"
|
||||||
},
|
},
|
||||||
"currentStatus": {
|
"currentStatus": {
|
||||||
@@ -46,7 +46,11 @@
|
|||||||
"effort": "small",
|
"effort": "small",
|
||||||
"details": {
|
"details": {
|
||||||
"configFormat": "ESM flat config (eslint.config.js)",
|
"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": [
|
"rules": [
|
||||||
"typescript-eslint/recommended",
|
"typescript-eslint/recommended",
|
||||||
"react-hooks/recommended",
|
"react-hooks/recommended",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.3.12",
|
"version": "0.3.13",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"@milkdown/react": "7.21.1",
|
"@milkdown/react": "7.21.1",
|
||||||
"@milkdown/utils": "7.21.1",
|
"@milkdown/utils": "7.21.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
|
"metona-toast": "file:../MetonaToast",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useFileOperations } from './hooks/useFileOperations'
|
|||||||
import { useDragDrop } from './hooks/useDragDrop'
|
import { useDragDrop } from './hooks/useDragDrop'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||||
import { useToast } from './hooks/useToast'
|
|
||||||
import { useConfirm } from './hooks/useConfirm'
|
import { useConfirm } from './hooks/useConfirm'
|
||||||
import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
|
import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
|
||||||
import { useStatusBarItem } from './hooks/useStatusBarItem'
|
import { useStatusBarItem } from './hooks/useStatusBarItem'
|
||||||
@@ -26,7 +25,6 @@ import { Preview } from './components/Preview/Preview'
|
|||||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||||
import { ToastContainer } from './components/Toast/Toast'
|
|
||||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
@@ -64,16 +62,15 @@ export function App() {
|
|||||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||||
const { darkMode, toggleDarkMode } = useTheme()
|
const { darkMode, toggleDarkMode } = useTheme()
|
||||||
const { saveViewMode } = useSettings()
|
const { saveViewMode } = useSettings()
|
||||||
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
|
||||||
const { confirm, confirmDialogProps } = useConfirm()
|
const { confirm, confirmDialogProps } = useConfirm()
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const [showAbout, setShowAbout] = useState(false)
|
||||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||||
|
|
||||||
useSettingsInit()
|
useSettingsInit()
|
||||||
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
useEffect(() => { loadFromDB() }, [loadFromDB])
|
||||||
|
|
||||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
|
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
|
||||||
useDragDrop(showToast)
|
useDragDrop()
|
||||||
useFileWatch()
|
useFileWatch()
|
||||||
useAutoSave()
|
useAutoSave()
|
||||||
|
|
||||||
@@ -152,7 +149,6 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
|
||||||
<DropOverlay />
|
<DropOverlay />
|
||||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
|||||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (window.electronAPI?.openExternal) {
|
if (window.electronAPI?.openExternal) {
|
||||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
|
||||||
} else {
|
} 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
|
|||||||
<div className="about-footer">
|
<div className="about-footer">
|
||||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||||
<Gitee size={16} />
|
<Gitee size={16} />
|
||||||
<span>gitee.com/thzxx/MarkLite</span>
|
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||||
</a>
|
</a>
|
||||||
<p>基于 Electron + React + TypeScript 构建</p>
|
<p>基于 Electron + React + TypeScript 构建</p>
|
||||||
<p className="about-copyright">© 2026 thzxx</p>
|
<p className="about-copyright">© 2026 thzxx</p>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
|||||||
setContent(activeTab.content)
|
setContent(activeTab.content)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setScrollTop(activeTab.scrollTop)
|
setScrollTop(activeTab.scrollTop)
|
||||||
setSelection()
|
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,13 +286,30 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setSelection = useCallback(() => {
|
const setSelection = useCallback((from?: number, to?: number) => {
|
||||||
// ProseMirror selection setting requires the view instance
|
const editor = editorRef.current
|
||||||
// which we access lazily; for now we focus the editor
|
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
|
const container = containerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||||
pmEditor?.focus()
|
pmEditor?.focus()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Get ProseMirror EditorView instance
|
// Get ProseMirror EditorView instance
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ export const SearchReplace = memo(function SearchReplace({
|
|||||||
// 重新搜索以获取匹配的最新位置
|
// 重新搜索以获取匹配的最新位置
|
||||||
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
||||||
if (freshMatches.length === 0) return
|
if (freshMatches.length === 0) return
|
||||||
if (freshMatches.length === 0) return
|
|
||||||
|
|
||||||
// 从后往前替换以保持位置正确
|
// 从后往前替换以保持位置正确
|
||||||
const tr = view.state.tr
|
const tr = view.state.tr
|
||||||
|
|||||||
@@ -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<ReturnType<typeof setTimeout> | 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<ToastType, string> = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✕',
|
|
||||||
warning: '⚠',
|
|
||||||
info: 'ℹ'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`toast-item toast-${toast.type} ${visible ? 'toast-visible' : ''}`}
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
>
|
|
||||||
<span className="toast-icon" aria-hidden="true">
|
|
||||||
{typeIcons[toast.type]}
|
|
||||||
</span>
|
|
||||||
<span className="toast-message">{toast.message}</span>
|
|
||||||
<button
|
|
||||||
className="toast-close"
|
|
||||||
onClick={handleClose}
|
|
||||||
aria-label="关闭通知"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UX-05: Toast 容器组件
|
|
||||||
* 支持多条堆叠、类型区分、关闭按钮和自动消失
|
|
||||||
*/
|
|
||||||
export const ToastContainer = React.memo(function ToastContainer({
|
|
||||||
toasts,
|
|
||||||
onDismiss
|
|
||||||
}: ToastContainerProps) {
|
|
||||||
if (toasts.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="toast-container" aria-label="通知区域">
|
|
||||||
{toasts.map(toast => (
|
|
||||||
<SingleToast key={toast.id} toast={toast} onDismiss={onDismiss} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
ToastContainer.displayName = 'ToastContainer'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ToastContainer } from './Toast'
|
|
||||||
export type { ToastType, ToastItem } from './Toast'
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D4: 基于视口的活跃标题追踪 hook
|
* D4: 基于视口的活跃标题追踪 hook
|
||||||
@@ -16,7 +16,6 @@ export function useActiveHeading(
|
|||||||
headings: { level: number; text: string }[]
|
headings: { level: number; text: string }[]
|
||||||
): number | null {
|
): number | null {
|
||||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
@@ -71,10 +70,8 @@ export function useActiveHeading(
|
|||||||
// 初始计算
|
// 初始计算
|
||||||
handleScroll()
|
handleScroll()
|
||||||
|
|
||||||
const observer = observerRef.current
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('scroll', handleScroll)
|
container.removeEventListener('scroll', handleScroll)
|
||||||
observer?.disconnect()
|
|
||||||
}
|
}
|
||||||
}, [containerRef, headings, handleScroll])
|
}, [containerRef, headings, handleScroll])
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useTabStore } from '../stores/tabStore'
|
|||||||
import { isAllowedFile } from '../lib/fileUtils'
|
import { isAllowedFile } from '../lib/fileUtils'
|
||||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||||
import { logError } from '../lib/errorHandler'
|
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 createTab = useTabStore(s => s.createTab)
|
||||||
|
|
||||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||||
@@ -50,7 +51,7 @@ export function useDragDrop(showToast: (msg: string) => void) {
|
|||||||
if (rejected > 0) {
|
if (rejected > 0) {
|
||||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||||
}
|
}
|
||||||
}, [createTab, showToast])
|
}, [createTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevent = (e: DragEvent) => {
|
const prevent = (e: DragEvent) => {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { useTabStore } from '../stores/tabStore'
|
|||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
import type { ToastType } from '../components/Toast/Toast'
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||||
* UX-02: 添加 loading 状态指示
|
* UX-02: 添加 loading 状态指示
|
||||||
*/
|
*/
|
||||||
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
|
export function useFileOperations() {
|
||||||
const createTab = useTabStore(s => s.createTab)
|
const createTab = useTabStore(s => s.createTab)
|
||||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||||
const setLoading = useEditorStore(s => s.setLoading)
|
const setLoading = useEditorStore(s => s.setLoading)
|
||||||
@@ -30,7 +30,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading('file-open', false)
|
setLoading('file-open', false)
|
||||||
}
|
}
|
||||||
}, [createTab, showToast, setLoading])
|
}, [createTab, setLoading])
|
||||||
|
|
||||||
const handleSave = useCallback(async (): Promise<void> => {
|
const handleSave = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -48,7 +48,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
logError('保存文件失败', error)
|
logError('保存文件失败', error)
|
||||||
showToast('保存失败', 'error')
|
showToast('保存失败', 'error')
|
||||||
}
|
}
|
||||||
}, [getActiveTab, showToast])
|
}, [getActiveTab])
|
||||||
|
|
||||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +63,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
logError('另存为失败', error)
|
logError('另存为失败', error)
|
||||||
showToast('另存为失败', 'error')
|
showToast('另存为失败', 'error')
|
||||||
}
|
}
|
||||||
}, [getActiveTab, showToast])
|
}, [getActiveTab])
|
||||||
|
|
||||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
|
|||||||
@@ -23,24 +23,26 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Tab / Ctrl+Shift+Tab
|
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||||
if (isCtrl && e.key === 'Tab') {
|
if (isCtrl && e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const { tabs, activeTabId, mruStack } = tabState
|
const { tabs, activeTabId, mruStack } = tabState
|
||||||
if (tabs.length > 1) {
|
if (tabs.length > 1) {
|
||||||
if (e.shiftKey) {
|
// mruStack[0] 是最近一个被切走的标签(即上一个活动标签)
|
||||||
if (mruStack.length > 0) {
|
if (mruStack.length > 0) {
|
||||||
const targetId = mruStack[0]
|
const targetId = mruStack[0]
|
||||||
if (tabs.find(t => t.id === targetId)) {
|
if (tabs.find(t => t.id === targetId)) {
|
||||||
tabState.switchToTab(targetId)
|
tabState.switchToTab(targetId)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
// MRU 栈为空或不合法时回退到顺序切换
|
||||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||||
const next = (idx + 1) % tabs.length
|
const next = e.shiftKey
|
||||||
|
? (idx - 1 + tabs.length) % tabs.length
|
||||||
|
: (idx + 1) % tabs.length
|
||||||
tabState.switchToTab(tabs[next].id)
|
tabState.switchToTab(tabs[next].id)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
|
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { useSidebarStore } from '../stores/sidebarStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
||||||
|
*
|
||||||
|
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
|
||||||
|
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
|
||||||
*/
|
*/
|
||||||
export function useSidebarResize() {
|
export function useSidebarResize() {
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
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('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
import { settingsRepository } from '../db/settingsRepository'
|
||||||
|
import { syncToastTheme } from '../lib/toast'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-04: 主题 hook
|
* AR-04: 主题 hook
|
||||||
@@ -15,6 +16,7 @@ export function useTheme() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', darkMode)
|
document.documentElement.classList.toggle('dark', darkMode)
|
||||||
settingsRepository.save({ darkMode })
|
settingsRepository.save({ darkMode })
|
||||||
|
syncToastTheme(darkMode)
|
||||||
}, [darkMode])
|
}, [darkMode])
|
||||||
|
|
||||||
return { darkMode, toggleDarkMode }
|
return { darkMode, toggleDarkMode }
|
||||||
|
|||||||
@@ -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<ToastItem[]>([])
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -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 }
|
||||||
@@ -388,14 +388,6 @@ body {
|
|||||||
background: var(--text-secondary);
|
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 {
|
#tab-list::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -1006,35 +998,6 @@ body {
|
|||||||
display: none;
|
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 */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -1371,138 +1334,6 @@ body {
|
|||||||
animation: spin 0.8s linear infinite;
|
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: 通用可访问性增强 ===== */
|
/* ===== UX-07: 通用可访问性增强 ===== */
|
||||||
|
|
||||||
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
||||||
|
|||||||
@@ -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 MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||||
export const SKIP_DIRS = new Set([
|
export const SKIP_DIRS = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user