chore: bump version to 0.3.11 — 状态栏扩展点架构
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# MarkLite v0.3.10 — 架构设计文档
|
||||
# MarkLite v0.3.11 — 架构设计文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}))
|
||||
@@ -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,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([
|
||||
|
||||
Reference in New Issue
Block a user