v0.2.0: 全面代码质量优化
- ESLint flat config + Prettier + EditorConfig - Markdown处理器LRU缓存 - Zustand选择器优化减少重渲染 - CodeMirror Compartment主题热切换 - Preview防抖(150ms) + IndexedDB去抖(500ms) - 组件拆分: App.tsx 310→104行, Sidebar.tsx 244→90行 - 统一错误处理 errorHandler.ts - ConfirmDialog替代原生confirm - LoadingSpinner加载状态 - Toast多条堆叠+类型区分 - 可访问性增强(ARIA属性、键盘导航) - Vitest测试框架(78个测试用例) - Git hooks(husky + lint-staged) - 项目文档(README.md, CONTRIBUTING.md)
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.2.0'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
@@ -0,0 +1 @@
|
||||
export { AboutDialog } from './AboutDialog'
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 保存焦点并在打开时聚焦确认按钮
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
// 关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
|
||||
return () => {
|
||||
previousFocusRef.current?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
@@ -0,0 +1 @@
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { UploadCloud } from '../Icons'
|
||||
|
||||
export function DropOverlay() {
|
||||
export const DropOverlay = React.memo(function DropOverlay() {
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -9,16 +9,13 @@ export function DropOverlay() {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => prev + 1)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = () => {
|
||||
setDragCounter(0)
|
||||
}
|
||||
@@ -39,11 +36,13 @@ export function DropOverlay() {
|
||||
if (dragCounter <= 0) return null
|
||||
|
||||
return (
|
||||
<div id="drop-overlay">
|
||||
<div id="drop-overlay" role="dialog" aria-label="拖拽文件以打开">
|
||||
<div className="drop-content">
|
||||
<UploadCloud size={64} />
|
||||
<p>释放文件以打开</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
DropOverlay.displayName = 'DropOverlay'
|
||||
|
||||
@@ -8,9 +8,8 @@ interface EditorProps {
|
||||
}
|
||||
|
||||
export function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
@@ -94,4 +93,4 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
)
|
||||
}
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
Editor.displayName = 'Editor'
|
||||
|
||||
@@ -5,16 +5,14 @@ interface EditorToolbarProps {
|
||||
viewRef: React.MutableRefObject<EditorView | null>
|
||||
}
|
||||
|
||||
export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
export const EditorToolbar = React.memo(function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
const insertFormatting = useCallback((before: string, after: string, placeholder: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const selected = view.state.sliceDoc(from, to)
|
||||
const text = selected || placeholder
|
||||
const insert = before + text + after
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + before.length, head: from + before.length + text.length }
|
||||
@@ -25,12 +23,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
const insertLinePrefix = useCallback((prefix: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
|
||||
const { from } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const currentLine = view.state.sliceDoc(line.from, line.to)
|
||||
|
||||
// 如果已经有前缀,移除它
|
||||
if (currentLine.startsWith(prefix)) {
|
||||
view.dispatch({
|
||||
changes: { from: line.from, to: line.from + prefix.length, insert: '' }
|
||||
@@ -46,11 +41,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
const insertBlock = useCallback((text: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
|
||||
const { from } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const insertPos = line.to + 1
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: insertPos, to: insertPos, insert: '\n' + text + '\n' },
|
||||
selection: { anchor: insertPos + 1, head: insertPos + 1 + text.length }
|
||||
@@ -59,49 +52,51 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
}, [viewRef])
|
||||
|
||||
return (
|
||||
<div className="editor-toolbar">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)">
|
||||
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)" aria-label="粗体">
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)" aria-label="斜体">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线" aria-label="删除线">
|
||||
<s>S</s>
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码" aria-label="行内代码">
|
||||
{'</>'}
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题">
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题" aria-label="一级标题">
|
||||
H1
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题" aria-label="二级标题">
|
||||
H2
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题" aria-label="三级标题">
|
||||
H3
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表">
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表" aria-label="无序列表">
|
||||
•≡
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表" aria-label="有序列表">
|
||||
1.
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用" aria-label="引用">
|
||||
❝
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块" aria-label="代码块">
|
||||
{'{ }'}
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接">
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接" aria-label="插入链接">
|
||||
🔗
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('', '图片描述')} title="图片">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('', '图片描述')} title="图片" aria-label="插入图片">
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
EditorToolbar.displayName = 'EditorToolbar'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { EditorState, type Extension } from '@codemirror/state'
|
||||
import { EditorState, Compartment, type Extension } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars, drawSelection, rectangularSelection } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
@@ -13,22 +13,35 @@ interface UseCodeMirrorOptions {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
// PF-04: 创建主题 Compartment,用于动态切换主题而不重建 EditorView
|
||||
const themeCompartment = new Compartment()
|
||||
|
||||
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const darkModeRef = useRef(darkMode)
|
||||
|
||||
// 始终保持 onChangeRef 为最新回调
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// 初始化编辑器
|
||||
// PF-04: darkMode 变化时通过 Compartment.reconfigure() 切换主题
|
||||
useEffect(() => {
|
||||
darkModeRef.current = darkMode
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(darkMode ? oneDark : [])
|
||||
})
|
||||
}, [darkMode])
|
||||
|
||||
// 初始化编辑器(仅在首次挂载时执行)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// 中文本地化(搜索/替换面板)
|
||||
const zhPhrases = EditorState.phrases.of({
|
||||
'Find': '查找',
|
||||
'Replace': '替换',
|
||||
@@ -65,15 +78,14 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
markdown({ base: markdownLanguage }),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(update => {
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !isExternalUpdate.current) {
|
||||
onChangeRef.current(update.state.doc.toString())
|
||||
}
|
||||
})
|
||||
}),
|
||||
themeCompartment.of(darkModeRef.current ? oneDark : [])
|
||||
]
|
||||
|
||||
if (darkMode) extensions.push(oneDark)
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions
|
||||
@@ -90,7 +102,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [darkMode]) // darkMode 变化时重建
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 外部内容更新(切换标签时)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
@@ -105,8 +118,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
// 获取/设置滚动位置
|
||||
const getScrollTop = useCallback(() => {
|
||||
|
||||
const getScrollTop = useCallback((): number => {
|
||||
return viewRef.current?.scrollDOM.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
@@ -116,8 +129,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取/设置选区
|
||||
const getSelection = useCallback(() => {
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const view = viewRef.current
|
||||
if (!view) return { from: 0, to: 0 }
|
||||
const ranges = view.state.selection.ranges
|
||||
@@ -130,6 +142,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
view.dispatch({ selection: { anchor: from, head: to } })
|
||||
view.focus()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewRef,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||
应用遇到了错误
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { File, Folder, ChevronRight } from '../Icons'
|
||||
import type { FileNode } from '../../types/file'
|
||||
|
||||
interface FileTreeProps {
|
||||
nodes: FileNode[]
|
||||
depth: number
|
||||
expandedDirs: string[]
|
||||
toggleDir: (path: string) => void
|
||||
activeTabId: string | null
|
||||
activeFilePath: string | null
|
||||
onFileClick: (path: string) => void
|
||||
}
|
||||
|
||||
/** AR-02: 从 Sidebar.tsx 提取的递归文件树组件 */
|
||||
export const FileTree = React.memo(function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
expandedDirs,
|
||||
toggleDir,
|
||||
activeFilePath,
|
||||
onFileClick
|
||||
}: FileTreeProps) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node: FileNode) => {
|
||||
const isExpanded = node.type === 'dir' && expandedDirs.includes(node.path)
|
||||
const isActive = node.type === 'file' && node.path === activeFilePath
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${isActive ? 'active' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
role="treeitem"
|
||||
aria-expanded={node.type === 'dir' ? isExpanded : undefined}
|
||||
aria-selected={isActive}
|
||||
aria-label={node.name}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'dir' ? (
|
||||
<>
|
||||
<span className={`tree-arrow ${isExpanded ? 'expanded' : ''}`} aria-hidden="true">
|
||||
<ChevronRight size={10} />
|
||||
</span>
|
||||
<span className="tree-icon" aria-hidden="true">
|
||||
<Folder size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ width: '16px', flexShrink: 0 }} />
|
||||
<span className="tree-icon" aria-hidden="true">
|
||||
<File size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tree-name">{node.name}</span>
|
||||
</div>
|
||||
{isExpanded && node.children && (
|
||||
<div role="group">
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={null}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
FileTree.displayName = 'FileTree'
|
||||
@@ -0,0 +1 @@
|
||||
export { FileTree } from './FileTree'
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
label?: string
|
||||
/** 是否全屏覆盖 */
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: 16,
|
||||
medium: 24,
|
||||
large: 36
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-02: 通用加载指示器组件
|
||||
*/
|
||||
export const LoadingSpinner = React.memo(function LoadingSpinner({
|
||||
size = 'medium',
|
||||
label,
|
||||
overlay = false
|
||||
}: LoadingSpinnerProps) {
|
||||
const px = sizeMap[size]
|
||||
|
||||
const spinner = (
|
||||
<div
|
||||
className={`loading-spinner loading-spinner-${size}`}
|
||||
role="status"
|
||||
aria-label={label || '加载中'}
|
||||
>
|
||||
<svg
|
||||
className="loading-spinner-svg"
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{label && <span className="loading-spinner-label">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!overlay) return spinner
|
||||
|
||||
return (
|
||||
<div className="loading-overlay" aria-busy="true">
|
||||
{spinner}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner'
|
||||
@@ -0,0 +1 @@
|
||||
export { LoadingSpinner } from './LoadingSpinner'
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
return (
|
||||
<div id="modified-banner">
|
||||
<div id="modified-banner" role="alert" aria-live="assertive">
|
||||
<span>文件已被外部程序修改</span>
|
||||
<button className="banner-btn" onClick={onReload}>重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss}>忽略</button>
|
||||
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">忽略</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ModifiedBanner.displayName = 'ModifiedBanner'
|
||||
|
||||
@@ -1,37 +1,63 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// 响应式渲染(无轮询,无竞态)
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
})
|
||||
}, [activeTabId, activeTab?.content])
|
||||
}
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href = link.getAttribute('href')
|
||||
const href: string | null = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
@@ -59,10 +85,17 @@ export function Preview() {
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
Preview.displayName = 'Preview'
|
||||
|
||||
@@ -1,116 +1,44 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File, Folder, ChevronRight } from '../Icons'
|
||||
import type { FileNode } from '../../types/file'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
||||
|
||||
export function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||
|
||||
// M-01: 归一化路径分隔符
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
|
||||
|
||||
// 自动展开到活动文件所在的目录
|
||||
useEffect(() => {
|
||||
if (!activeFilePath || !rootPath) return
|
||||
const normActive = norm(activeFilePath)
|
||||
const normRoot = norm(rootPath)
|
||||
if (!normActive.startsWith(normRoot)) return
|
||||
|
||||
const dirsToExpand: string[] = []
|
||||
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
|
||||
let prev = ''
|
||||
while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) {
|
||||
prev = dir
|
||||
dirsToExpand.push(dir)
|
||||
dir = dir.replace(/[/\\][^/\\]+$/, '')
|
||||
}
|
||||
|
||||
if (dirsToExpand.length > 0) {
|
||||
expandDirs(dirsToExpand)
|
||||
}
|
||||
}, [activeFilePath, rootPath, expandDirs])
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (dirPath) {
|
||||
setRootPath(dirPath)
|
||||
// 展开根目录
|
||||
expandDirs([dirPath])
|
||||
const result = await window.electronAPI.readDirTree(dirPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
}
|
||||
}, [rootPath, setTree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => {
|
||||
refreshTree()
|
||||
})
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
const handleMouseUp = () => setIsResizing(false)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) {
|
||||
switchToTab(existing.id)
|
||||
} else if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(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)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab])
|
||||
|
||||
// M-01: 独立文件区(归一化路径比较)
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
@@ -120,123 +48,61 @@ export function Sidebar() {
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="sidebar" ref={sidebarRef}>
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{/* 独立文件区 */}
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section">
|
||||
<div className="independent-files-header">已打开的文件</div>
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
>
|
||||
<span className="tree-icon">
|
||||
<File size={14} />
|
||||
</span>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot"> •</span>}
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件夹目录树 */}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header">文件夹目录树</div>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={handleFileClick}
|
||||
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 文件树递归组件
|
||||
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, activeFilePath, onFileClick }: {
|
||||
nodes: FileNode[]
|
||||
depth: number
|
||||
expandedDirs: string[]
|
||||
toggleDir: (path: string) => void
|
||||
activeTabId: string | null
|
||||
activeFilePath: string | null
|
||||
onFileClick: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${node.type === 'file' && node.path === activeFilePath ? 'active' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
role="treeitem"
|
||||
onClick={() => {
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'dir' ? (
|
||||
<>
|
||||
<span className={`tree-arrow ${expandedDirs.includes(node.path) ? 'expanded' : ''}`}>
|
||||
<ChevronRight size={10} />
|
||||
</span>
|
||||
<span className="tree-icon">
|
||||
<Folder size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ width: '16px', flexShrink: 0 }} />
|
||||
<span className="tree-icon">
|
||||
<File size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tree-name">{node.name}</span>
|
||||
</div>
|
||||
{node.type === 'dir' && expandedDirs.includes(node.path) && node.children && (
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
FileTree.displayName = 'FileTree'
|
||||
@@ -1,18 +1,34 @@
|
||||
import React from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
export function StatusBar() {
|
||||
// B-04: 直接选择数据而非函数引用,确保响应式更新
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tab = tabs.find(t => t.id === activeTabId) ?? null
|
||||
export const StatusBar = React.memo(function StatusBar() {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const loadingStates = useEditorStore(s => s.loadingStates)
|
||||
|
||||
// 判断是否有任何加载状态激活
|
||||
const hasLoading = Object.values(loadingStates).some(Boolean)
|
||||
const loadingLabel = loadingStates['file-open']
|
||||
? '正在打开文件...'
|
||||
: loadingStates['dir-load']
|
||||
? '正在加载目录...'
|
||||
: loadingStates['markdown-render']
|
||||
? '正在渲染...'
|
||||
: ''
|
||||
|
||||
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">
|
||||
{tab ? (tab.filePath ? getFileName(tab.filePath) : '未命名') : '就绪'}
|
||||
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-right">
|
||||
@@ -22,6 +38,6 @@ export function StatusBar() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
StatusBar.displayName = 'StatusBar'
|
||||
StatusBar.displayName = 'StatusBar'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
@@ -22,6 +24,7 @@ export function TabBar() {
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
@@ -70,15 +73,21 @@ export function TabBar() {
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab])
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
@@ -98,47 +107,71 @@ export function TabBar() {
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab])
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(() => {
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs])
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(() => {
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs])
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(() => {
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight])
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
@@ -148,65 +181,73 @@ export function TabBar() {
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" />
|
||||
<div className="tab-context-item" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
TabBar.displayName = 'TabBar'
|
||||
|
||||
@@ -1,11 +1,103 @@
|
||||
interface ToastProps {
|
||||
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
|
||||
}
|
||||
|
||||
export function Toast({ message }: ToastProps) {
|
||||
/** 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 id="toast-notification" className="show">
|
||||
{message}
|
||||
<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'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToastContainer } from './Toast'
|
||||
export type { ToastType, ToastItem } from './Toast'
|
||||
@@ -11,38 +11,50 @@ interface ToolbarProps {
|
||||
onShowAbout: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
|
||||
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
|
||||
return (
|
||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||
<div className="toolbar-left">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)">
|
||||
<div className="toolbar-left" role="group" aria-label="文件操作">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
|
||||
<FolderOpen size={18} />
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)">
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
|
||||
<Save size={18} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" />
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
|
||||
<div className="toolbar-divider" role="separator" />
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('editor')}
|
||||
title="编辑 (Ctrl+1)"
|
||||
aria-label="编辑模式"
|
||||
aria-pressed={viewMode === 'editor'}
|
||||
>
|
||||
<EditMode size={18} />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+2)">
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
title="预览 (Ctrl+2)"
|
||||
aria-label="预览模式"
|
||||
aria-pressed={viewMode === 'preview'}
|
||||
>
|
||||
<PreviewMode size={18} />
|
||||
<span>预览</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题">
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
|
||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于">
|
||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||
<Info size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Toolbar.displayName = 'Toolbar'
|
||||
Toolbar.displayName = 'Toolbar'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
@@ -9,29 +9,29 @@ interface WelcomeScreenProps {
|
||||
onOpenRecent?: (filePath: string) => void
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
|
||||
export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
|
||||
const [recentFiles, setRecentFiles] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
recentFilesRepository.getAll(10).then(files => {
|
||||
recentFilesRepository.getAll(10).then((files: string[]) => {
|
||||
setRecentFiles(files)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="welcome-screen">
|
||||
<div id="welcome-screen" role="main" aria-label="欢迎页面">
|
||||
<div className="welcome-content">
|
||||
<div className="welcome-icon">
|
||||
<div className="welcome-icon" aria-hidden="true">
|
||||
<AppIcon size={80} />
|
||||
</div>
|
||||
<h1>欢迎使用 MarkLite</h1>
|
||||
<p>一款轻量级的 Markdown 编辑器</p>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn primary" onClick={onOpen}>
|
||||
<div className="welcome-actions" role="group" aria-label="快速操作">
|
||||
<button className="welcome-btn primary" onClick={onOpen} aria-label="打开文件">
|
||||
<WelcomeFile size={20} />
|
||||
打开文件
|
||||
</button>
|
||||
<button className="welcome-btn secondary" onClick={onNew}>
|
||||
<button className="welcome-btn secondary" onClick={onNew} aria-label="新建文件">
|
||||
<WelcomeNew size={20} />
|
||||
新建文件
|
||||
</button>
|
||||
@@ -40,15 +40,17 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp
|
||||
{recentFiles.length > 0 && (
|
||||
<div className="welcome-recent">
|
||||
<h3>最近打开</h3>
|
||||
<div className="recent-list">
|
||||
{recentFiles.map(filePath => (
|
||||
<div className="recent-list" role="list" aria-label="最近打开的文件">
|
||||
{recentFiles.map((filePath: string) => (
|
||||
<button
|
||||
key={filePath}
|
||||
className="recent-item"
|
||||
onClick={() => onOpenRecent?.(filePath)}
|
||||
title={filePath}
|
||||
role="listitem"
|
||||
aria-label={`打开 ${getFileName(filePath)}`}
|
||||
>
|
||||
<span className="recent-icon">
|
||||
<span className="recent-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
@@ -62,11 +64,13 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="welcome-tips">
|
||||
<div className="welcome-tips" role="note" aria-label="使用提示">
|
||||
<p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p>
|
||||
<p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+F 搜索 | Ctrl+1/2 视图</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
WelcomeScreen.displayName = 'WelcomeScreen'
|
||||
|
||||
Reference in New Issue
Block a user