diff --git a/package.json b/package.json index 06e73a7..c8abcf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.3.4", + "version": "0.3.5", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 21b52bd..5d1cfa6 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -197,15 +197,16 @@ export function registerIpcHandlers( // 标签切换 ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { - state.activeFilePath = filePath || null - if (filePath && !validatePath(filePath)) { + const normalizedPath = filePath || null + if (normalizedPath && !validatePath(normalizedPath)) { fileWatcher.stop() return } - fileWatcher.start(filePath || '') + state.activeFilePath = normalizedPath + fileWatcher.start(normalizedPath || '') const win = getMainWindow() if (win && !win.isDestroyed()) { - win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite') + win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite') } }) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 407a63a..74ccc84 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -74,7 +74,7 @@ export function App() { const handleReloadModified = useCallback(async () => { if (!externallyModified?.filePath || !window.electronAPI) return const result = await window.electronAPI.readFile(externallyModified.filePath) - if (result.success && result.content) { + if (result.success && result.content !== undefined) { const tab = tabs.find(t => t.filePath === externallyModified.filePath) if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) } } diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 6993401..3144867 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -9,7 +9,7 @@ import { useSidebarResize } from '../../hooks/useSidebarResize' import { useFolderOperations } from '../../hooks/useFolderOperations' import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' import { OutlinePanel, parseHeadings } from '../OutlinePanel' -import { getEditorView } from '../../stores/editorStore' +import { getEditorView, useEditorStore } from '../../stores/editorStore' import { TextSelection } from '@milkdown/prose/state' const norm = (p: string) => p.replace(/\\/g, '/') @@ -29,6 +29,7 @@ export const Sidebar = React.memo(function Sidebar() { const activeFilePath = activeTab?.filePath ?? null const { sidebarRef, startResize } = useSidebarResize() const { handleOpenFolder } = useFolderOperations() + const setLoading = useEditorStore(s => s.setLoading) useAutoExpandDir(activeFilePath) // Parse headings from active tab content @@ -56,12 +57,17 @@ export const Sidebar = React.memo(function Sidebar() { const existing = tabs.find(t => t.filePath === path) if (existing) { switchToTab(existing.id); return } if (!window.electronAPI) return - const result = await window.electronAPI.readFile(path) - if (result.success && result.content) { - createTab(path, result.content) - recentFilesRepository.add(path) + setLoading('file-open', true) + try { + const result = await window.electronAPI.readFile(path) + if (result.success && result.content !== undefined) { + createTab(path, result.content) + recentFilesRepository.add(path) + } + } finally { + setLoading('file-open', false) } - }, [tabs, switchToTab, createTab]) + }, [tabs, switchToTab, createTab, setLoading]) const independentFiles = tabs.filter(t => { if (!t.filePath) return false diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx index ee2bd4c..a6899fa 100644 --- a/src/renderer/components/StatusBar/StatusBar.tsx +++ b/src/renderer/components/StatusBar/StatusBar.tsx @@ -1,12 +1,14 @@ 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' export const StatusBar = React.memo(function StatusBar() { const activeTab = useTabStore(s => s.getActiveTab()) const loadingStates = useEditorStore(s => s.loadingStates) + const stats = useDocStats(activeTab?.content) // 判断是否有任何加载状态激活 const hasLoading = Object.values(loadingStates).some(Boolean) @@ -32,6 +34,22 @@ export const StatusBar = React.memo(function StatusBar() {
+ {activeTab && ( + <> + + {stats.lines} 行 + + | + + {stats.words} 词 + + | + + {stats.chars} 字符 + + | + + )} UTF-8 | Markdown diff --git a/src/renderer/hooks/__tests__/useDocStats.test.ts b/src/renderer/hooks/__tests__/useDocStats.test.ts new file mode 100644 index 0000000..20cd170 --- /dev/null +++ b/src/renderer/hooks/__tests__/useDocStats.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { computeDocStats } from '../useDocStats' + +describe('computeDocStats', () => { + it('should return zeros for undefined content', () => { + expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 }) + }) + + it('should return zeros for null content', () => { + expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 }) + }) + + it('should return zeros for empty string', () => { + expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 }) + }) + + it('should count single word', () => { + const result = computeDocStats('hello') + expect(result.words).toBe(1) + expect(result.chars).toBe(5) + expect(result.charsNoSpace).toBe(5) + expect(result.lines).toBe(1) + }) + + it('should count multiple words', () => { + const result = computeDocStats('hello world') + expect(result.words).toBe(2) + expect(result.chars).toBe(11) // 'hello world' = 11 chars + expect(result.charsNoSpace).toBe(10) // without the space + }) + + it('should count characters without spaces', () => { + const result = computeDocStats('a b c') + expect(result.chars).toBe(5) + expect(result.charsNoSpace).toBe(3) + }) + + it('should count lines', () => { + const result = computeDocStats('line1\nline2\nline3') + expect(result.lines).toBe(3) + expect(result.words).toBe(3) + }) + + it('should handle Windows line endings', () => { + const result = computeDocStats('line1\r\nline2\r\nline3') + expect(result.lines).toBe(3) + }) + + it('should handle trailing newline', () => { + const result = computeDocStats('hello\n') + expect(result.lines).toBe(2) + expect(result.words).toBe(1) + }) + + it('should count markdown content correctly', () => { + const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n' + const result = computeDocStats(md) + expect(result.words).toBe(17) + expect(result.lines).toBe(7) + expect(result.chars).toBe(md.length) + }) + + it('should handle whitespace-only content', () => { + const result = computeDocStats(' \n \n ') + expect(result.words).toBe(0) + expect(result.chars).toBe(9) + expect(result.charsNoSpace).toBe(0) + expect(result.lines).toBe(3) + }) + + it('should handle content with non-ASCII characters', () => { + const result = computeDocStats('中文测试 日本語 한국어') + expect(result.words).toBe(3) + expect(result.chars).toBe(12) + }) +}) diff --git a/src/renderer/hooks/useDocStats.ts b/src/renderer/hooks/useDocStats.ts new file mode 100644 index 0000000..7bf1a8c --- /dev/null +++ b/src/renderer/hooks/useDocStats.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react' + +export interface DocStats { + /** 单词数(按空白字符分割) */ + words: number + /** 总字符数(含空白字符) */ + chars: number + /** 字符数(不含空白字符) */ + charsNoSpace: number + /** 行数 */ + lines: number +} + +/** + * 计算文档统计信息:单词数、字符数(含/不含空格)、行数。 + * 对空文档或 undefined 返回全零值。 + */ +export function computeDocStats(content: string | undefined | null): DocStats { + if (!content) { + return { words: 0, chars: 0, charsNoSpace: 0, lines: 0 } + } + + const chars = content.length + const charsNoSpace = content.replace(/\s/g, '').length + const words = content.trim() + ? content.trim().split(/\s+/).length + : 0 + const lines = content === '' ? 0 : content.split(/\r?\n/).length + + return { words, chars, charsNoSpace, lines } +} + +/** + * Hook:根据文档内容实时计算统计信息。 + * 使用 useMemo 避免不必要的重新计算。 + */ +export function useDocStats(content: string | undefined | null): DocStats { + return useMemo(() => computeDocStats(content), [content]) +} diff --git a/src/renderer/hooks/useFileOperations.ts b/src/renderer/hooks/useFileOperations.ts index 9087329..c1daa27 100644 --- a/src/renderer/hooks/useFileOperations.ts +++ b/src/renderer/hooks/useFileOperations.ts @@ -70,7 +70,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) => setLoading('file-open', true) try { const result = await window.electronAPI.readFile(filePath) - if (result.success) { + if (result.success && result.content !== undefined) { createTab(filePath, result.content) recentFilesRepository.add(filePath) setTimeout(() => useTabStore.getState().saveToDB(), 100) diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index 81c4782..f2eeb54 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -566,6 +566,10 @@ body { color: var(--border); } +.status-stat { + white-space: nowrap; +} + /* Drop Overlay */ #drop-overlay { position: fixed;