diff --git a/DESIGN.md b/DESIGN.md
index 9fbe047..42f4387 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,4 +1,4 @@
-# MarkLite v0.3.10 — 架构设计文档
+# MarkLite v0.3.11 — 架构设计文档
## 1. 项目概述
diff --git a/README.md b/README.md
index 09139ab..f57f734 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
diff --git a/package.json b/package.json
index e09e776..0e3af01 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index c5896dc..cba24b2 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -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 (
+
+ )
+}
+
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 => {
@@ -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() {
)}
-
+
{showAbout && }
diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx
index 25f4376..c4fdfb2 100644
--- a/src/renderer/components/StatusBar/StatusBar.tsx
+++ b/src/renderer/components/StatusBar/StatusBar.tsx
@@ -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 (
- {hasLoading && (
-
-
- {loadingLabel}
-
- )}
-
- {activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
-
+ {sorted.left.map(({ id, Component }) => (
+
+ ))}
- {activeTab && (
- <>
-
- {stats.lines} 行
-
- |
-
- {stats.words} 词
-
- |
-
- {stats.chars} 字符
-
- |
- >
- )}
- {activeTab?.filePath && onToggleAutoSave !== undefined && (
- <>
-
- |
- >
- )}
- UTF-8
- |
- Markdown
+ {sorted.right.map(({ id, Component }, i) => (
+
+ {i > 0 && |}
+
+
+ ))}
)
diff --git a/src/renderer/hooks/useAutoSave.ts b/src/renderer/hooks/useAutoSave.ts
index 156347f..9eb0ec0 100644
--- a/src/renderer/hooks/useAutoSave.ts
+++ b/src/renderer/hooks/useAutoSave.ts
@@ -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 }
}
diff --git a/src/renderer/hooks/useStatusBarItem.ts b/src/renderer/hooks/useStatusBarItem.ts
new file mode 100644
index 0000000..0b31e04
--- /dev/null
+++ b/src/renderer/hooks/useStatusBarItem.ts
@@ -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])
+}
diff --git a/src/renderer/hooks/useStatusBarItems.tsx b/src/renderer/hooks/useStatusBarItems.tsx
new file mode 100644
index 0000000..d4fec3b
--- /dev/null
+++ b/src/renderer/hooks/useStatusBarItems.tsx
@@ -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 (
+
+ {activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
+
+ )
+}
+
+/** 加载指示器 — 打开文件/加载目录/渲染中 */
+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 (
+
+
+ {label}
+
+ )
+}
+
+/** 文档统计 — 行数 / 词数 / 字符数 */
+function DocStatsItem() {
+ const activeTab = useTabStore(s => s.getActiveTab())
+ const stats = useDocStats(activeTab?.content)
+ if (!activeTab) return null
+
+ return (
+ <>
+ {stats.lines} 行
+ |
+ {stats.words} 词
+ |
+ {stats.chars} 字符
+ >
+ )
+}
+
+/** 静态项 — 编码 */
+function EncodingItem() {
+ return UTF-8
+}
+
+/** 静态项 — 语言 */
+function LangItem() {
+ return Markdown
+}
+
+/**
+ * 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])
+}
diff --git a/src/renderer/stores/autoSaveStore.ts b/src/renderer/stores/autoSaveStore.ts
new file mode 100644
index 0000000..80e6b0a
--- /dev/null
+++ b/src/renderer/stores/autoSaveStore.ts
@@ -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) => void
+}
+
+export const useAutoSaveStore = create((set) => ({
+ isAutoSaving: false,
+ autoSaveEnabled: true,
+ setState: (partial) => set(partial)
+}))
diff --git a/src/renderer/stores/statusBarStore.ts b/src/renderer/stores/statusBarStore.ts
new file mode 100644
index 0000000..e836c76
--- /dev/null
+++ b/src/renderer/stores/statusBarStore.ts
@@ -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
+ register: (item: StatusBarItem) => void
+ unregister: (id: string) => void
+}
+
+export const useStatusBarStore = create((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 })
+ }
+}))
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index cbfc5b4..05ff5dc 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -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([