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:
2026-07-22 15:55:45 +08:00
parent 77fdf03ed7
commit 43b0778849
24 changed files with 170 additions and 387 deletions
+3 -7
View File
@@ -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() {
</div>
</div>
<StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
@@ -10,9 +10,9 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): 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
<div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} />
<span>gitee.com/thzxx/MarkLite</span>
<span>git.metona.cn/MetonaTeam/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p className="about-copyright">© 2026 thzxx</p>
+2 -2
View File
@@ -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(() => {
+24 -7
View File
@@ -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
@@ -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
-103
View File
@@ -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'
-2
View File
@@ -1,2 +0,0 @@
export { ToastContainer } from './Toast'
export type { ToastType, ToastItem } from './Toast'
+1 -4
View File
@@ -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<number | null>(null)
const observerRef = useRef<IntersectionObserver | null>(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])
+3 -2
View File
@@ -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) => {
+5 -5
View File
@@ -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<void> => {
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<void> => {
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<void> => {
if (!window.electronAPI) return
+13 -11
View File
@@ -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
}
+13 -1
View File
@@ -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)
+2
View File
@@ -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 }
-45
View File
@@ -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 }
}
+41
View File
@@ -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 }
-169
View File
@@ -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 为所有交互元素提供清晰的焦点指示 */