chore: bump version to 0.3.11 — 状态栏扩展点架构

This commit is contained in:
2026-06-23 11:36:46 +08:00
parent b65e34e288
commit f707b5c7ee
11 changed files with 270 additions and 75 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# MarkLite v0.3.10 — 架构设计文档
# MarkLite v0.3.11 — 架构设计文档
## 1. 项目概述
+1 -1
View File
@@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.10-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Version-v0.3.11-orange?style=flat-square" alt="Version">
</p>
<p align="center">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.3.10",
"version": "0.3.11",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
+35 -2
View File
@@ -14,6 +14,10 @@ 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'
import { useAutoSaveStore } from './stores/autoSaveStore'
import { toggleAutoSaveExternal } from './hooks/useAutoSave'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
@@ -29,6 +33,25 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
/** 状态栏 — 自动保存开关组件 */
function AutoSaveStatusItem() {
const activeTab = useTabStore(s => s.getActiveTab())
const { isAutoSaving, autoSaveEnabled } = useAutoSaveStore()
if (!activeTab?.filePath) return null
return (
<button
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
onClick={toggleAutoSaveExternal}
>
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
</button>
)
}
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
@@ -52,7 +75,7 @@ export function App() {
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast)
useFileWatch()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
useAutoSave()
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
@@ -69,6 +92,16 @@ export function App() {
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners()
// ── 状态栏扩展点架构 ───────────────────────────────────
useDefaultStatusBarItems()
useStatusBarItem({
id: 'statusbar.auto-save',
alignment: 'right',
priority: 200,
Component: AutoSaveStatusItem
})
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
@@ -118,7 +151,7 @@ export function App() {
)}
</div>
</div>
<StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} onToggleAutoSave={toggleAutoSave} />
<StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
+27 -69
View File
@@ -1,81 +1,39 @@
import React from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { useDocStats } from '../../hooks/useDocStats'
import { getFileName } from '../../lib/fileUtils'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
import React, { useMemo } from 'react'
import { useStatusBarStore, type StatusBarItem } from '../../stores/statusBarStore'
interface StatusBarProps {
isAutoSaving?: boolean
autoSaveEnabled?: boolean
onToggleAutoSave?: () => void
}
/**
* StatusBar — 纯渲染层
*
* 不包含任何业务逻辑。所有状态栏项由各功能模块通过
* useStatusBarStore.register() 注入,本组件按 alignment + priority 排序渲染。
* 每个 item.Component 是独立 React 组件,可自由使用 hooks。
*/
export const StatusBar = React.memo(function StatusBar({
isAutoSaving = false,
autoSaveEnabled = true,
onToggleAutoSave
}: StatusBarProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const loadingStates = useEditorStore(s => s.loadingStates)
const stats = useDocStats(activeTab?.content)
export const StatusBar = React.memo(function StatusBar() {
const items = useStatusBarStore(s => s.items)
// 判断是否有任何加载状态激活
const hasLoading = Object.values(loadingStates).some(Boolean)
const loadingLabel = loadingStates['file-open']
? '正在打开文件...'
: loadingStates['dir-load']
? '正在加载目录...'
: loadingStates['markdown-render']
? '正在渲染...'
: ''
const sorted = useMemo(() => {
const list = Object.values(items)
if (list.length === 0) return { left: [] as StatusBarItem[], right: [] as StatusBarItem[] }
const byAlignment = (a: 'left' | 'right') =>
list.filter(i => i.alignment === a).sort((x, y) => x.priority - y.priority)
return { left: byAlignment('left'), right: byAlignment('right') }
}, [items])
return (
<div id="statusbar" role="status" aria-live="polite">
<div className="status-left">
{hasLoading && (
<span className="status-loading" aria-busy="true">
<LoadingSpinner size="small" />
<span>{loadingLabel}</span>
</span>
)}
<span id="status-text">
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
</span>
{sorted.left.map(({ id, Component }) => (
<Component key={id} />
))}
</div>
<div className="status-right">
{activeTab && (
<>
<span className="status-stat" title="行数" aria-label={`${stats.lines}`}>
{stats.lines}
</span>
<span className="status-divider">|</span>
<span className="status-stat" title="单词数" aria-label={`${stats.words} 个单词`}>
{stats.words}
</span>
<span className="status-divider">|</span>
<span className="status-stat" title="字符数(含空格)" aria-label={`${stats.chars} 个字符`}>
{stats.chars}
</span>
<span className="status-divider">|</span>
</>
)}
{activeTab?.filePath && onToggleAutoSave !== undefined && (
<>
<button
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
onClick={onToggleAutoSave}
>
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
</button>
<span className="status-divider">|</span>
</>
)}
<span id="status-encoding">UTF-8</span>
<span className="status-divider">|</span>
<span id="status-lang">Markdown</span>
{sorted.right.map(({ id, Component }, i) => (
<React.Fragment key={id}>
{i > 0 && <span className="status-divider">|</span>}
<Component />
</React.Fragment>
))}
</div>
</div>
)
+19
View File
@@ -1,10 +1,17 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useAutoSaveStore } from '../stores/autoSaveStore'
import { logError } from '../lib/errorHandler'
/** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
let _toggleAutoSave: (() => void) | null = null
export function toggleAutoSaveExternal(): void {
_toggleAutoSave?.()
}
/**
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
* and saves automatically after a pause in editing for file-backed tabs.
@@ -36,6 +43,9 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
})
}, [])
// 暴露给外部(状态栏按钮)
_toggleAutoSave = toggleAutoSave
useEffect(() => {
mountedRef.current = true
@@ -93,5 +103,14 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
}
}, [])
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
useEffect(() => {
useAutoSaveStore.getState().setState({ autoSaveEnabled })
}, [autoSaveEnabled])
useEffect(() => {
useAutoSaveStore.getState().setState({ isAutoSaving })
}, [isAutoSaving])
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect } from 'react'
import { useStatusBarStore, type StatusBarItem } from '../stores/statusBarStore'
/**
* 便捷 hook — 挂载时注册一个状态栏项,卸载时自动清除。
*
* 适合在 App.tsx 或自定义 hook 中注册需要响应式更新的状态栏项。
* 当 item 对象变化时(通过 deps 控制),旧项会被新项替换。
*
* @param item - 状态栏项(含 Component 函数组件)
*/
export function useStatusBarItem(item: StatusBarItem | null): void {
const register = useStatusBarStore(s => s.register)
const unregister = useStatusBarStore(s => s.unregister)
useEffect(() => {
if (!item) return
register(item)
return () => unregister(item.id)
}, [item, register, unregister])
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect } from 'react'
import { useStatusBarStore } from '../stores/statusBarStore'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { useDocStats } from './useDocStats'
import { getFileName } from '../lib/fileUtils'
import { LoadingSpinner } from '../components/LoadingSpinner/LoadingSpinner'
/** 文件信息 — 文件名或"就绪" */
function FileInfoItem() {
const activeTab = useTabStore(s => s.getActiveTab())
return (
<span id="status-text">
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
</span>
)
}
/** 加载指示器 — 打开文件/加载目录/渲染中 */
function LoadingItem() {
const loadingStates = useEditorStore(s => s.loadingStates)
const hasLoading = Object.values(loadingStates).some(Boolean)
if (!hasLoading) return null
const label = loadingStates['file-open']
? '正在打开文件...'
: loadingStates['dir-load']
? '正在加载目录...'
: loadingStates['markdown-render']
? '正在渲染...'
: ''
return (
<span className="status-loading" aria-busy="true">
<LoadingSpinner size="small" />
<span>{label}</span>
</span>
)
}
/** 文档统计 — 行数 / 词数 / 字符数 */
function DocStatsItem() {
const activeTab = useTabStore(s => s.getActiveTab())
const stats = useDocStats(activeTab?.content)
if (!activeTab) return null
return (
<>
<span className="status-stat" title="行数" aria-label={`${stats.lines}`}>{stats.lines} </span>
<span className="status-divider">|</span>
<span className="status-stat" title="单词数" aria-label={`${stats.words} 个单词`}>{stats.words} </span>
<span className="status-divider">|</span>
<span className="status-stat" title="字符数(含空格)" aria-label={`${stats.chars} 个字符`}>{stats.chars} </span>
</>
)
}
/** 静态项 — 编码 */
function EncodingItem() {
return <span id="status-encoding">UTF-8</span>
}
/** 静态项 — 语言 */
function LangItem() {
return <span id="status-lang">Markdown</span>
}
/**
* useDefaultStatusBarItems — 注册所有默认状态栏项(不含 auto-save)。
*
* auto-save 项由 App.tsx 单独注册,因为它需要从 autoSaveStore
* 读取状态并通过 toggleAutoSaveExternal() 触发切换。
*/
export function useDefaultStatusBarItems() {
const register = useStatusBarStore(s => s.register)
const unregister = useStatusBarStore(s => s.unregister)
useEffect(() => {
register({ id: 'statusbar.file-info', alignment: 'left', priority: 0, Component: FileInfoItem })
register({ id: 'statusbar.loading', alignment: 'left', priority: 1, Component: LoadingItem })
register({ id: 'statusbar.doc-stats', alignment: 'right', priority: 100, Component: DocStatsItem })
register({ id: 'statusbar.encoding', alignment: 'right', priority: 300, Component: EncodingItem })
register({ id: 'statusbar.lang', alignment: 'right', priority: 400, Component: LangItem })
return () => {
unregister('statusbar.file-info')
unregister('statusbar.loading')
unregister('statusbar.doc-stats')
unregister('statusbar.encoding')
unregister('statusbar.lang')
}
}, [register, unregister])
}
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
/**
* 轻量 auto-save 状态 store
*
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
* useAutoSave hook 负责写入本 store。
*/
interface AutoSaveState {
isAutoSaving: boolean
autoSaveEnabled: boolean
}
interface AutoSaveStore extends AutoSaveState {
setState: (partial: Partial<AutoSaveState>) => void
}
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
isAutoSaving: false,
autoSaveEnabled: true,
setState: (partial) => set(partial)
}))
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand'
import type { ComponentType } from 'react'
/**
* 状态栏扩展点架构
*
* 功能模块通过 register/unregister 注入自己的状态栏组件,
* StatusBar 只负责按 alignment + priority 排序渲染。
* 避免 StatusBar 随功能膨胀,实现类似 VS Code contributions 的注册模式。
*
* 每个 StatusBarItem 的 Component 是独立的 React 函数组件(无 props),
* 内部使用 hooks 读取所需 store 状态。组件在 StatusBar 的渲染中挂载,
* 因此 hooks 调用在合法的 React 组件上下文中。
*/
export interface StatusBarItem {
id: string
alignment: 'left' | 'right'
/** 同侧排序权重,数字越小越靠左(left)或越靠中(right) */
priority: number
Component: ComponentType
}
interface StatusBarState {
/** 以 id 为 key,方便覆盖和删除 */
items: Record<string, StatusBarItem>
register: (item: StatusBarItem) => void
unregister: (id: string) => void
}
export const useStatusBarStore = create<StatusBarState>((set, get) => ({
items: {},
register: (item: StatusBarItem) => {
const { items } = get()
if (items[item.id]?.Component === item.Component) return
set({ items: { ...items, [item.id]: item } })
},
unregister: (id: string) => {
const { items } = get()
if (!(id in items)) return
const next = { ...items }
delete next[id]
set({ items: next })
}
}))
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.3.10'
export const APP_VERSION = 'v0.3.11'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([