release: v0.4.0 — MetonaEditor 集成与架构重构

feat(editor): 将 Milkdown/ProseMirror 替换为 @metona-team/metona-editor v0.1.3
  - 三模式视图 (edit/split/preview) 由编辑器内置工具栏切换
  - searchReplace + imagePaste 预设插件
  - unified/rehype 渲染管线通过 render 钩子集成
  - 主题双向同步 (应用暗色模式 ↔ 编辑器主题)

feat(toast): metona-toast 迁移为 @metona-team/metona-toast v2.0.1

refactor: 删除冗余组件与代码
  - 移除 SourceEditor、Preview、SearchReplace、EditorToolbar、useMilkdown
  - 移除 StatusBar 组件及状态栏扩展架构(编辑器内置底栏替代)
  - 移除 useSettings、useStatusBarItem、useStatusBarItems、statusBarStore
  - 移除 EditMode/PreviewMode/SourceMode 图标

refactor(ui): 简化布局
  - 工具栏移除模式切换按钮,新增自动保存开关
  - useKeyboard 移除 Ctrl+1/2/3/B/I 快捷键
  - Editor 三模式统一由 MetonaEditor 容器渲染

chore: 版本号 v0.3.13 → v0.4.0
docs: 全面更新 README/DESIGN/CONTRIBUTING/DEVSETUP
This commit is contained in:
thzxx
2026-07-23 22:55:45 +08:00
parent 43b0778849
commit 05e5667e48
33 changed files with 460 additions and 4335 deletions
+185 -97
View File
@@ -1,127 +1,215 @@
import React, { useEffect, useCallback, useState, useRef } from 'react'
import { callCommand } from '@milkdown/utils'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
import React, { useEffect, useRef } from 'react'
import MeEditor from '@metona-team/metona-editor'
import type { MarkdownEditor, EditMode, PluginObject } from '@metona-team/metona-editor'
import { useTabStore } from '../../stores/tabStore'
import { useMilkdown } from './useMilkdown'
import { EditorToolbar } from './EditorToolbar'
import { SearchReplace } from '../SearchReplace'
import { setEditorViewGetter } from '../../stores/editorStore'
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { settingsRepository } from '../../db/settingsRepository'
import { renderMarkdownSync } from '../../lib/markdown'
interface EditorProps {
darkMode: boolean
}
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
function mapViewMode(vm: string): EditMode {
if (vm === 'source') return 'edit'
if (vm === 'preview') return 'preview'
return 'split'
}
/** 将 MetonaEditor mode 反向映射到应用 viewMode */
function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
if (mode === 'edit') return 'source'
if (mode === 'preview') return 'preview'
return 'editor'
}
/** 组装实例级插件列表 */
function buildPlugins(): PluginObject[] {
const plugins: PluginObject[] = []
// 搜索/替换(Ctrl+F / Ctrl+H
if (MeEditor.presetPlugins?.searchReplace) {
plugins.push(MeEditor.presetPlugins.searchReplace)
}
// 粘贴图片 → base64
if (MeEditor.presetPlugins?.imagePaste) {
plugins.push(MeEditor.presetPlugins.imagePaste)
}
return plugins
}
/**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
const [showSearch, setShowSearch] = useState(false)
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(null)
const currentContentRef = useRef('')
const {
containerRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
} = useMilkdown({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
if (!activeTabId) return
// B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated),
// 避免将文档误标记为已修改
const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId)
if (tab?.content === value) return
updateTabContent(activeTabId, value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
darkMode
})
// 稳定的回调引用
const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId
// Load content when switching tabs (only trigger on tab switch, not content edits)
// ── 初始化 MetonaEditor ──────────────────────────────────
useEffect(() => {
if (!activeTab) return
if (activeTab.content !== currentContentRef.current) {
currentContentRef.current = activeTab.content
// Queue setContent after Milkdown editor has finished async create()
requestAnimationFrame(() => {
setContent(activeTab.content)
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
})
})
}
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
const container = containerRef.current
if (!container) return
const editor = MeEditor.create(container, {
value: activeTab?.content ?? '',
mode: mapViewMode(viewMode),
height: '100%',
toolbar: [
'bold', 'italic', 'strikethrough', 'underline', 'code', '|',
'h1', 'h2', 'h3', '|',
'quote', 'ul', 'ol', 'indent', 'outdent', '|',
'link', 'image', 'table', 'hr', '|',
'undo', 'redo', '|',
'edit', 'split', 'preview', 'fullscreen'
],
locale: 'zh-CN',
theme: darkMode ? 'dark' : 'light',
placeholder: '在此输入 Markdown 内容...',
spellcheck: false,
tabSize: 2,
wordCount: true,
plugins: buildPlugins(),
// 使用 unified 管线渲染,保留图片路径修复能力
render: (md: string) => {
const tabId = activeTabIdRef.current
const filePath = tabId
? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
: null
return renderMarkdownSync(md, filePath)
},
// 内容变化 → 同步到 tabStore
onChange: (value: string) => {
const tabId = activeTabIdRef.current
if (!tabId) return
const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
if (tab?.content === value) return
currentContentRef.current = value
updateTabContent(tabId, value)
setModified(tabId, true)
},
// 模式切换 → 同步到 editorStore 并持久化
onModeChange: (mode: EditMode) => {
const mapped = reverseMapMode(mode)
setViewMode(mapped)
settingsRepository.save({ viewMode: mapped })
}
})
editorRef.current = editor
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab?.content ?? ''
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => {
if (!activeTabId) return
// Snapshot the current editor state before the new tab replaces content
const scrollPos = getScrollTop()
const sel = getSelection()
// Use a microtask to ensure we save before React re-renders the new tab content
Promise.resolve().then(() => {
updateTabScroll(activeTabId, {
scrollTop: scrollPos,
selectionStart: sel.from,
selectionEnd: sel.to
})
})
editor.destroy()
editorRef.current = null
setMetonaEditorGetter(() => null)
}
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// 仅在挂载时创建一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── 标签切换:同步内容到编辑器 ──────────────────────────
useEffect(() => {
if (!activeTab || !editorRef.current) return
if (activeTab.content === currentContentRef.current) return
currentContentRef.current = activeTab.content
// silent: true — 不触发 onChange,避免重复更新 tabStore
editorRef.current.setValue(activeTab.content, { silent: true })
// 恢复滚动位置
requestAnimationFrame(() => {
const c = containerRef.current
if (!c) return
const textarea = c.querySelector('textarea')
if (textarea) {
textarea.scrollTop = activeTab.scrollTop
}
const preview = c.querySelector('.me-preview') as HTMLElement | null
if (preview) {
preview.scrollTop = activeTab.scrollTop
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Register getView for OutlinePanel navigation
// ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
useEffect(() => {
setEditorViewGetter(getView)
return () => setEditorViewGetter(() => null)
}, [getView])
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'b') {
e.preventDefault()
action((editor) => {
editor.action(callCommand(toggleStrongCommand.key))
})
}
if (isCtrl && e.key === 'i') {
e.preventDefault()
action((editor) => {
editor.action(callCommand(toggleEmphasisCommand.key))
})
}
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
e.preventDefault()
setShowSearch(true)
}
const c = containerRef.current
return () => {
if (!activeTabId || !editorRef.current || !c) return
const textarea = c.querySelector('textarea')
const preview = c.querySelector('.me-preview') as HTMLElement | null
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
updateTabScroll(activeTabId, { scrollTop })
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [action])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 暗色模式同步 ──────────────────────────────────────────
useEffect(() => {
try {
const themeName = darkMode ? 'dark' : 'light'
// 1. 全局主题(documentElement + localStorage
MeEditor.setTheme(themeName)
// 2. 覆写当前实例 wrapper 的 inline CSS 变量
// MeEditor.setTheme 只更新 documentElement,不更新 wrapper
// 而 create() 时写入的 inline 变量优先级最高,必须手动覆写
const wrapper = containerRef.current?.querySelector('.me-wrapper') as HTMLElement | null
if (wrapper) {
const config: Record<string, string> = MeEditor.themes.getThemeConfig(themeName) as Record<string, string>
wrapper.style.setProperty('--md-bg', config.bg)
wrapper.style.setProperty('--md-text', config.text)
wrapper.style.setProperty('--md-border', config.border)
wrapper.style.setProperty('--md-shadow', config.shadow)
if (config.hoverShadow) wrapper.style.setProperty('--md-hover-shadow', config.hoverShadow)
if (config.toolbarBg) wrapper.style.setProperty('--md-toolbar-bg', config.toolbarBg)
if (config.textareaBg) wrapper.style.setProperty('--md-textarea-bg', config.textareaBg)
if (config.previewBg) wrapper.style.setProperty('--md-preview-bg', config.previewBg)
if (config.codeBg) wrapper.style.setProperty('--md-code-bg', config.codeBg)
if (config.codeText) wrapper.style.setProperty('--md-code-text', config.codeText)
if (config.accent) wrapper.style.setProperty('--md-accent', config.accent)
if (config.muted) wrapper.style.setProperty('--md-muted', config.muted)
}
} catch {
// 容错
}
}, [darkMode])
// ── 视图模式同步 ──────────────────────────────────────────
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const targetMode = mapViewMode(viewMode)
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
}
}, [viewMode])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar action={action} />
{showSearch && (
<SearchReplace
getView={getView}
onClose={() => setShowSearch(false)}
/>
)}
<div ref={containerRef} className="milkdown-wrapper" />
<div ref={containerRef} className="metona-editor-wrapper" />
</div>
)
})
@@ -1,160 +0,0 @@
import React, { useCallback } from 'react'
import type { Editor } from '@milkdown/core'
import { callCommand } from '@milkdown/utils'
import type { $Command } from '@milkdown/utils'
import {
toggleStrongCommand,
toggleEmphasisCommand,
wrapInHeadingCommand,
wrapInBlockquoteCommand,
wrapInBulletListCommand,
wrapInOrderedListCommand,
createCodeBlockCommand,
toggleInlineCodeCommand,
insertImageCommand,
toggleLinkCommand,
insertHrCommand
} from '@milkdown/preset-commonmark'
import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
interface EditorToolbarProps {
action: (fn: (editor: Editor) => void) => void
}
export const EditorToolbar = React.memo(function EditorToolbar({ action }: EditorToolbarProps) {
const exec = useCallback(<T,>(command: $Command<T>, payload?: T) => {
action((editor) => {
try {
editor.action(callCommand(command.key, payload))
} catch {
// command may not be registered
}
})
}, [action])
const handleHeading = useCallback((level: number) => {
exec(wrapInHeadingCommand, level)
}, [exec])
return (
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleStrongCommand)}
title="粗体 (Ctrl+B)"
aria-label="粗体"
>
<strong>B</strong>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleEmphasisCommand)}
title="斜体 (Ctrl+I)"
aria-label="斜体"
>
<em>I</em>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleStrikethroughCommand)}
title="删除线"
aria-label="删除线"
>
<s>S</s>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleInlineCodeCommand)}
title="行内代码"
aria-label="行内代码"
>
{'</>'}
</button>
<div className="toolbar-divider-sm" role="separator" />
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(1)}
title="标题"
aria-label="一级标题"
>
H1
</button>
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(2)}
title="二级标题"
aria-label="二级标题"
>
H2
</button>
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(3)}
title="三级标题"
aria-label="三级标题"
>
H3
</button>
<div className="toolbar-divider-sm" role="separator" />
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInBulletListCommand)}
title="无序列表"
aria-label="无序列表"
>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInOrderedListCommand)}
title="有序列表"
aria-label="有序列表"
>
1.
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInBlockquoteCommand)}
title="引用"
aria-label="引用"
>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(createCodeBlockCommand)}
title="代码块"
aria-label="代码块"
>
{'{ }'}
</button>
<div className="toolbar-divider-sm" role="separator" />
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleLinkCommand, { href: '', title: '' })}
title="链接"
aria-label="插入链接"
>
🔗
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(insertImageCommand, { src: '', alt: '' })}
title="图片"
aria-label="插入图片"
>
🖼
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(insertHrCommand)}
title="分割线"
aria-label="插入分割线"
>
</button>
</div>
)
})
EditorToolbar.displayName = 'EditorToolbar'
@@ -1,344 +0,0 @@
import { useCallback, useRef, useEffect } from 'react'
import {
Editor,
rootCtx,
defaultValueCtx,
editorViewCtx,
prosePluginsCtx
} from '@milkdown/core'
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
import { commonmark } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm'
import { history } from '@milkdown/plugin-history'
import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { indent } from '@milkdown/plugin-indent'
import { trailing } from '@milkdown/plugin-trailing'
import { clipboard } from '@milkdown/plugin-clipboard'
import { Plugin, PluginKey, TextSelection } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view'
import type { EditorState } from '@milkdown/prose/state'
// --- Auto-pair plugin (D2) ---
const PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
function createAutoPairPlugin(): Plugin {
return new Plugin({
props: {
handleTextInput(view, from, to, text) {
// 选中文本时用配对符号包裹
if (from !== to && PAIRS[text]) {
const close = PAIRS[text]
const { tr } = view.state
tr.insertText(text + view.state.doc.textBetween(from, to) + close, from, to)
view.dispatch(tr)
return true
}
return false
},
handleKeyDown(view, event) {
const char = event.key
// Backspace: 删除配对符号(光标在两个配对符号中间时)
// 必须在 PAIRS 检查之前处理,因为 'Backspace' 不在 PAIRS 映射中
if (char === 'Backspace') {
const { state } = view
const { from, to } = state.selection
if (from !== to || from < 2) return false
const before = state.doc.textBetween(from - 1, from)
const after = state.doc.textBetween(from, from + 1)
if (PAIRS[before] === after) {
const tr = state.tr.delete(from - 1, from + 1)
view.dispatch(tr)
return true
}
return false
}
// 处理配对符号的自动闭合
if (!PAIRS[char]) return false
const { state } = view
const { from, to } = state.selection
if (from !== to) {
// 有选区时:包裹
const close = PAIRS[char]
const tr = state.tr.insertText(char + state.doc.textBetween(from, to) + close, from, to)
view.dispatch(tr)
return true
}
// 无选区:插入配对并光标置中
const close = PAIRS[char]
const tr = state.tr.insertText(char + close, from)
// 光标置于中间
tr.setSelection(
TextSelection.create(tr.doc, from + 1)
)
view.dispatch(tr)
return true
}
}
})
}
// --- Search highlight plugin ---
export interface SearchMatch {
from: number
to: number
}
export interface SearchPluginState {
matches: SearchMatch[]
currentIndex: number
query: string
}
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
function createSearchPlugin(): Plugin<SearchPluginState> {
return new Plugin<SearchPluginState>({
key: searchPluginKey,
state: {
init(): SearchPluginState {
return { matches: [], currentIndex: -1, query: '' }
},
apply(tr, value): SearchPluginState {
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
if (meta) return meta
// When the document changes, map existing match positions through the change
// but only if there's an active search
if (tr.docChanged && value.query) {
// The SearchReplace component will re-search when doc changes,
// but we map positions so decorations stay roughly correct in the interim
return { ...value }
}
return value
}
},
props: {
decorations(state: EditorState) {
const pluginState = searchPluginKey.getState(state)
if (!pluginState || pluginState.matches.length === 0) {
return null
}
const decos: Decoration[] = pluginState.matches.map((match, index) => {
const cls =
index === pluginState.currentIndex
? 'search-match-active'
: 'search-match-highlight'
return Decoration.inline(match.from, match.to, { class: cls })
})
return DecorationSet.create(state.doc, decos)
}
}
})
}
// --- Hook ---
interface UseMilkdownOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
}
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
const isExternalUpdate = useRef(false)
const initialContentRef = useRef(content)
// H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件
const setContentRequestId = useRef(0)
// Keep onChangeRef fresh
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Initialize editor
useEffect(() => {
if (!containerRef.current) return
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current)
// Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
// Configure listener for content changes
const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown === prevMarkdown) return
// H-03: 只有未被外部更新抑制时才触发 onChange。
// 计数器匹配确保用户按键不会在 setContent 期间被丢弃。
if (setContentRequestId.current === 0 && !isExternalUpdate.current) {
onChangeRef.current(markdown)
}
})
// Listen for blur events (used for potential future state saving)
lm.blur(() => {
// blur handler placeholder
})
})
.use(commonmark)
.use(gfm)
.use(history)
.use(listener)
.use(indent)
.use(trailing)
.use(clipboard)
let cancelled = false
editor.create().then((created) => {
if (cancelled) {
// 组件已卸载,立即销毁以避免内存泄漏
created.destroy()
return
}
editorRef.current = created
}).catch((err) => {
// eslint-disable-next-line no-console -- editor creation failure must be reported
console.error('Milkdown editor creation failed:', err)
})
return () => {
cancelled = true
editorRef.current?.destroy()
editorRef.current = null
}
}, [])
// Dark mode: update CSS custom properties on the container
useEffect(() => {
const container = containerRef.current
if (!container) return
if (darkMode) {
container.classList.add('milkdown-dark')
} else {
container.classList.remove('milkdown-dark')
}
}, [darkMode])
// External content update (tab switch)
const setContent = useCallback((newContent: string) => {
const editor = editorRef.current
if (!editor) return
// H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制
const requestId = ++setContentRequestId.current
isExternalUpdate.current = true
try {
editor.action(milkdownReplaceAll(newContent))
} catch {
// replaceAll may fail if editor is not fully ready
} finally {
// 仅当没有新的 setContent 启动时才重置标志
if (setContentRequestId.current === requestId) {
setContentRequestId.current = 0
isExternalUpdate.current = false
}
}
}, [])
// Scroll position management
const getScrollTop = useCallback((): number => {
const container = containerRef.current
if (!container) return 0
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
return scroller?.scrollTop ?? container.scrollTop ?? 0
}, [])
const setScrollTop = useCallback((top: number) => {
const container = containerRef.current
if (!container) return
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
if (scroller) {
scroller.scrollTop = top
} else {
container.scrollTop = top
}
}, [])
// Selection management
const getSelection = useCallback((): { from: number; to: number } => {
const editor = editorRef.current
if (!editor) return { from: 0, to: 0 }
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (view?.state?.selection) {
return { from: view.state.selection.from, to: view.state.selection.to }
}
return { from: 0, to: 0 }
})
} catch {
return { from: 0, to: 0 }
}
}, [])
const setSelection = useCallback((from?: number, to?: number) => {
const editor = editorRef.current
if (!editor) return
try {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (!view) return
const docSize = view.state.doc.content.size
// 恢复保存的选区,越界时夹紧到文档末尾
const safeFrom = Math.min(from ?? 0, docSize)
const safeTo = Math.min(to ?? safeFrom, docSize)
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, safeFrom, safeTo)
)
view.dispatch(tr)
view.focus()
})
} catch {
// 编辑器未就绪或选区无效,回退到仅 focus
const container = containerRef.current
if (!container) return
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
pmEditor?.focus()
}
}, [])
// Get ProseMirror EditorView instance
const getView = useCallback(() => {
const editor = editorRef.current
if (!editor) return null
try {
return editor.action((ctx) => ctx.get(editorViewCtx))
} catch {
return null
}
}, [])
// Execute a Milkdown action (for toolbar commands)
const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current
if (!editor) return
fn(editor)
}, [])
return {
containerRef,
editorRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
}
}
-29
View File
@@ -38,35 +38,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
)
}
export function EditMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
<circle cx="18" cy="6" r="1" fill="currentColor"/>
</svg>
)
}
export function PreviewMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="12" cy="12" r="1" fill="currentColor"/>
</svg>
)
}
export function SourceMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
)
}
export function Moon({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
-102
View File
@@ -1,102 +0,0 @@
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 const Preview = React.memo(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 setLoading = useEditorStore(s => s.setLoading)
// PF-07: 响应式渲染 + 防抖
useEffect(() => {
if (!activeTab) {
setHtml('')
setRendering(false)
setLoading('markdown-render', false)
return
}
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)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const link = (e.target as HTMLElement).closest('a')
if (!link) return
e.preventDefault()
const href: string | null = link.getAttribute('href')
if (!href) return
if (href.startsWith('#')) {
const target = previewRef.current?.querySelector(href)
if (target) target.scrollIntoView({ behavior: 'smooth' })
return
}
try {
const url = new URL(href)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
} catch {
return
}
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
} else {
window.open(href, '_blank', 'noopener')
}
}, [])
return (
<div
ref={previewRef}
id="preview"
className="markdown-body"
role="region"
aria-label="Markdown预览"
aria-live="polite"
aria-busy={rendering}
onClick={handleClick}
>
{rendering && html === '' && (
<div className="preview-loading" aria-label="正在渲染Markdown">
<LoadingSpinner size="medium" label="正在渲染..." />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
})
Preview.displayName = 'Preview'
@@ -1,446 +0,0 @@
import React, { useState, useCallback, useRef, useEffect, memo } from 'react'
import type { EditorView } from '@milkdown/prose/view'
import { TextSelection } from '@milkdown/prose/state'
import type { Node as ProseMirrorNode } from '@milkdown/prose/model'
import { searchPluginKey, type SearchMatch, type SearchPluginState } from '../Editor/useMilkdown'
interface SearchReplaceProps {
/** 获取 ProseMirror EditorView 实例 */
getView: () => EditorView | null
/** 关闭面板 */
onClose: () => void
}
/**
* 在 ProseMirror 文档中查找所有匹配位置
* D6: 支持正则表达式 + 大小写敏感
*/
function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean, useRegex: boolean): SearchMatch[] {
const matches: SearchMatch[] = []
if (!query) return matches
if (useRegex) {
let regex: RegExp
try {
const flags = caseSensitive ? 'g' : 'gi'
regex = new RegExp(query, flags)
} catch {
// 正则语法错误 — 返回空,UI 通过 regexError 状态提示用户
return []
}
doc.descendants((node, pos) => {
if (!node.isText) return true
const text = node.text || ''
regex.lastIndex = 0
let result: RegExpExecArray | null
while ((result = regex.exec(text)) !== null) {
matches.push({ from: pos + result.index, to: pos + result.index + result[0].length })
if (result[0].length === 0) regex.lastIndex++ // 避免空匹配死循环
}
return true
})
return matches
}
// 普通字符串匹配
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
doc.descendants((node, pos) => {
if (!node.isText) return true
const text = node.text || ''
const searchText = caseSensitive ? text : text.toLowerCase()
let offset = 0
let idx: number
while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) {
matches.push({ from: pos + idx, to: pos + idx + query.length })
offset = idx + 1
}
return true
})
return matches
}
/**
* 滚动到指定文档位置
*/
function scrollToPos(view: EditorView, pos: number) {
try {
const { node } = view.domAtPos(pos)
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
} catch {
// Position may be out of range
}
}
/**
* 更新 ProseMirror plugin state(触发 decorations 重绘)
*/
function dispatchSearchState(view: EditorView, state: SearchPluginState) {
const tr = view.state.tr.setMeta(searchPluginKey, state)
view.dispatch(tr)
}
/**
* 清除搜索高亮(dispatch 空状态)
*/
function clearSearchDecorations(view: EditorView) {
dispatchSearchState(view, { matches: [], currentIndex: -1, query: '' })
}
/**
* SearchReplace — 编辑器内搜索/替换面板
*
* 功能:
* - Ctrl+F 打开搜索,Ctrl+H 打开替换
* - 大小写敏感切换
* - Enter 下一个,Shift+Enter 上一个
* - 替换当前匹配 / 全部替换
* - ESC 关闭面板
*/
export const SearchReplace = memo(function SearchReplace({
getView,
onClose
}: SearchReplaceProps) {
const [query, setQuery] = useState('')
const [replacement, setReplacement] = useState('')
const [showReplace, setShowReplace] = useState(false)
const [caseSensitive, setCaseSensitive] = useState(false)
const [useRegex, setUseRegex] = useState(false)
const [regexError, setRegexError] = useState(false)
const [matchCount, setMatchCount] = useState(0)
const [currentMatch, setCurrentMatch] = useState(-1)
const searchInputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
// Keep latest state in refs for use in callbacks without stale closures
const matchesRef = useRef<SearchMatch[]>([])
const currentIndexRef = useRef(-1)
const caseSensitiveRef = useRef(false)
const useRegexRef = useRef(false)
const queryRef = useRef('')
// Sync refs
caseSensitiveRef.current = caseSensitive
useRegexRef.current = useRegex
queryRef.current = query
// 执行搜索
const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => {
const view = getView()
if (!view) return
// D6: 检查正则语法
setRegexError(false)
if (rx && searchQuery.trim()) {
try {
new RegExp(searchQuery, 'g')
} catch {
setRegexError(true)
return
}
}
if (!searchQuery.trim()) {
clearSearchDecorations(view)
matchesRef.current = []
currentIndexRef.current = -1
setMatchCount(0)
setCurrentMatch(-1)
return
}
const matches = findMatches(view.state.doc, searchQuery, cs, rx)
matchesRef.current = matches
const newIdx = matches.length > 0 ? 0 : -1
currentIndexRef.current = newIdx
setMatchCount(matches.length)
setCurrentMatch(newIdx)
// Dispatch to ProseMirror plugin to render decorations
dispatchSearchState(view, { matches, currentIndex: newIdx, query: searchQuery })
// Scroll to first match
if (matches.length > 0) {
scrollToPos(view, matches[0].from)
}
}, [getView])
// 搜索框输入变化
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
doSearch(value, caseSensitiveRef.current, useRegexRef.current)
}, [doSearch])
// 大小写切换
const toggleCaseSensitive = useCallback(() => {
const newCS = !caseSensitiveRef.current
setCaseSensitive(newCS)
doSearch(queryRef.current, newCS, useRegexRef.current)
}, [doSearch])
// 正则切换
const toggleRegex = useCallback(() => {
const newRx = !useRegexRef.current
setUseRegex(newRx)
doSearch(queryRef.current, caseSensitiveRef.current, newRx)
}, [doSearch])
// 导航到下一个/上一个匹配
const navigateMatch = useCallback((direction: 1 | -1) => {
const view = getView()
if (!view) return
const matches = matchesRef.current
if (matches.length === 0) return
let next = currentIndexRef.current + direction
if (next >= matches.length) next = 0
if (next < 0) next = matches.length - 1
currentIndexRef.current = next
setCurrentMatch(next)
// Update plugin state to move the active highlight
dispatchSearchState(view, {
matches,
currentIndex: next,
query: queryRef.current
})
// Scroll to match and select it
const match = matches[next]
try {
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, match.from, match.to)
)
view.dispatch(tr)
} catch {
// Selection range might be invalid
}
scrollToPos(view, match.from)
}, [getView])
// 替换当前匹配
const replaceCurrent = useCallback(() => {
const view = getView()
if (!view) return
const matches = matchesRef.current
const idx = currentIndexRef.current
if (idx < 0 || idx >= matches.length) return
const match = matches[idx]
// Replace text in ProseMirror document
const tr = view.state.tr.insertText(replacement, match.from, match.to)
view.dispatch(tr)
// Re-search after replacement (document changed)
// Use requestAnimationFrame to let ProseMirror process the transaction
requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
})
}, [getView, replacement, doSearch])
// 全部替换
const replaceAll = useCallback(() => {
const view = getView()
if (!view) return
// 重新搜索以获取匹配的最新位置
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
if (freshMatches.length === 0) return
// 从后往前替换以保持位置正确
const tr = view.state.tr
for (let i = freshMatches.length - 1; i >= 0; i--) {
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
}
view.dispatch(tr)
// 更新ref
matchesRef.current = []
// 替换后重新搜索
requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
})
}, [getView, replacement, doSearch])
// 键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
const view = getView()
if (view) clearSearchDecorations(view)
onClose()
return
}
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
navigateMatch(-1)
} else {
navigateMatch(1)
}
}
}, [onClose, navigateMatch, getView])
// 全局 Ctrl+F / Ctrl+H 拦截
useEffect(() => {
const handleGlobalKey = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'f') {
e.preventDefault()
setShowReplace(false)
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
if (isCtrl && e.key === 'h') {
e.preventDefault()
setShowReplace(true)
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
document.addEventListener('keydown', handleGlobalKey)
return () => document.removeEventListener('keydown', handleGlobalKey)
}, [])
// 打开时聚焦搜索框
useEffect(() => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
}, [])
// 清理高亮(组件卸载时)
useEffect(() => {
return () => {
const view = getView()
if (view) clearSearchDecorations(view)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div
ref={panelRef}
className="search-replace-panel"
role="search"
aria-label="搜索和替换"
onKeyDown={handleKeyDown}
>
<div className="search-row">
<div className="search-input-group">
<input
ref={searchInputRef}
type="text"
className="search-input"
placeholder="搜索..."
value={query}
onChange={handleQueryChange}
aria-label="搜索文本"
/>
<span className="search-count" aria-live="polite">
{regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
</span>
</div>
<button
className={`search-btn ${caseSensitive ? 'active' : ''}`}
onClick={toggleCaseSensitive}
title="区分大小写 (点击切换)"
aria-label="区分大小写"
aria-pressed={caseSensitive}
>
Aa
</button>
<button
className={`search-btn ${useRegex ? 'active' : ''}`}
onClick={toggleRegex}
title="使用正则表达式 (点击切换)"
aria-label="正则表达式"
aria-pressed={useRegex}
>
.*
</button>
<button
className="search-btn"
onClick={() => navigateMatch(-1)}
title="上一个 (Shift+Enter)"
aria-label="上一个匹配"
disabled={matchCount === 0}
>
</button>
<button
className="search-btn"
onClick={() => navigateMatch(1)}
title="下一个 (Enter)"
aria-label="下一个匹配"
disabled={matchCount === 0}
>
</button>
<button
className="search-btn"
onClick={() => setShowReplace(!showReplace)}
title="切换替换模式 (Ctrl+H)"
aria-label="替换模式"
aria-expanded={showReplace}
>
</button>
<button
className="search-btn search-close"
onClick={() => {
const view = getView()
if (view) clearSearchDecorations(view)
onClose()
}}
title="关闭 (Esc)"
aria-label="关闭搜索"
>
</button>
</div>
{showReplace && (
<div className="search-row">
<div className="search-input-group">
<input
type="text"
className="search-input"
placeholder="替换..."
value={replacement}
onChange={e => setReplacement(e.target.value)}
aria-label="替换文本"
/>
</div>
<button
className="search-btn replace-btn"
onClick={replaceCurrent}
title="替换当前"
aria-label="替换当前匹配"
disabled={currentMatch < 0}
>
</button>
<button
className="search-btn replace-btn"
onClick={replaceAll}
title="全部替换"
aria-label="替换全部匹配"
disabled={matchCount === 0}
>
</button>
</div>
)}
</div>
)
})
SearchReplace.displayName = 'SearchReplace'
@@ -1 +0,0 @@
export { SearchReplace } from './SearchReplace'
+36 -31
View File
@@ -11,11 +11,14 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { useActiveHeading } from '../../hooks/useActiveHeading'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel'
import { getEditorView, useEditorStore } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
@@ -43,12 +46,11 @@ export const Sidebar = React.memo(function Sidebar() {
return parseHeadings(activeTab.content)
}, [activeTab?.content])
// D4: 追踪预览面板中的活跃标题
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview
const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => {
// 仅在预览模式时获取 preview DOM 节点
if (viewMode === 'preview') {
previewRef.current = document.getElementById('preview')
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
} else {
previewRef.current = null
}
@@ -59,37 +61,40 @@ export const Sidebar = React.memo(function Sidebar() {
headings
)
// Navigate to heading in ProseMirror document tree
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
const view = getEditorView()
if (!view) return
// Navigate to heading in MetonaEditor
const handleHeadingNavigate = useCallback((heading: Heading) => {
const editor = getMetonaEditor()
if (!editor) return
try {
let foundPos: number | null = null
let currentIndex = 0
// 获取当前内容,查找标题文本在源代码中的位置
const content = editor.getValue()
const headingPattern = new RegExp(
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`,
'm'
)
const match = headingPattern.exec(content)
if (!match) return
view.state.doc.descendants((node, pos) => {
if (foundPos !== null) return false
if (node.type.name === 'heading' && node.attrs.level === heading.level) {
if (node.textContent.trim() === heading.text) {
if (currentIndex === index) {
foundPos = pos
return false
}
currentIndex++
}
}
return true
})
const pos = match.index
if (foundPos === null) return
// 通过 DOM 操作滚动 textarea 到对应位置
const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null
if (!container) return
const tr = view.state.tr
.setSelection(TextSelection.create(view.state.doc, foundPos, foundPos))
.scrollIntoView()
view.dispatch(tr)
view.focus()
const textarea = container.querySelector('textarea')
if (!textarea) return
// 估算滚动位置(简单方法:按行数比例)
const linesBefore = content.substring(0, pos).split('\n').length
const lineHeight = 24 // 估算行高
textarea.scrollTop = linesBefore * lineHeight
// 设置光标位置
textarea.focus()
textarea.setSelectionRange(pos, pos)
} catch {
// Position may be invalid if content has changed
// 导航失败,静默忽略
}
}, [])
@@ -1,109 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
interface SourceEditorProps {
darkMode: boolean
}
/**
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
*
* A2: 完全受控 — 所有变更通过 updateTabContent 驱动,
* 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。
*/
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复
const pendingSelectionRef = useRef<number | null>(null)
useEffect(() => {
if (pendingSelectionRef.current === null) return
const textarea = textareaRef.current
if (!textarea) return
const pos = pendingSelectionRef.current
// microtask:在 React 完成 DOM 更新后恢复光标
requestAnimationFrame(() => {
textarea.selectionStart = pos
textarea.selectionEnd = pos
})
pendingSelectionRef.current = null
}, [activeTab?.content])
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!activeTabId) return
updateTabContent(activeTabId, e.target.value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isCtrl = e.ctrlKey || e.metaKey
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
// Tab 插入两个空格而非跳转焦点
if (e.key === 'Tab') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const newValue = value.substring(0, start) + ' ' + value.substring(end)
const newPos = start + 2
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
pendingSelectionRef.current = newPos
return
}
// Ctrl+B: 粗体
if (isCtrl && e.key === 'b') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2
return
}
// Ctrl+I: 斜体
if (isCtrl && e.key === 'i') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1
return
}
}, [activeTabId, updateTabContent, setModified])
return (
<div className={`source-editor-wrapper${darkMode ? ' source-editor-dark' : ''}`}>
<textarea
ref={textareaRef}
className="source-editor-textarea"
value={activeTab?.content ?? ''}
onChange={handleChange}
onKeyDown={handleKeyDown}
spellCheck={false}
wrap="off"
aria-label="Markdown 源码编辑器"
placeholder="在此输入 Markdown 内容..."
/>
</div>
)
})
SourceEditor.displayName = 'SourceEditor'
@@ -1,42 +0,0 @@
import React, { useMemo } from 'react'
import { useStatusBarStore, type StatusBarItem } from '../../stores/statusBarStore'
/**
* StatusBar — 纯渲染层
*
* 不包含任何业务逻辑。所有状态栏项由各功能模块通过
* useStatusBarStore.register() 注入,本组件按 alignment + priority 排序渲染。
* 每个 item.Component 是独立 React 组件,可自由使用 hooks。
*/
export const StatusBar = React.memo(function StatusBar() {
const items = useStatusBarStore(s => s.items)
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">
{sorted.left.map(({ id, Component }) => (
<Component key={id} />
))}
</div>
<div className="status-right">
{sorted.right.map(({ id, Component }, i) => (
<React.Fragment key={id}>
{i > 0 && <span className="status-divider">|</span>}
<Component />
</React.Fragment>
))}
</div>
</div>
)
})
StatusBar.displayName = 'StatusBar'
@@ -1,64 +0,0 @@
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { getFileName } from '../../lib/fileUtils'
import { useDocStats } from '../../hooks/useDocStats'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
/** 文件信息 — 文件名或"就绪" */
export function FileInfoItem() {
const activeTab = useTabStore(s => s.getActiveTab())
return (
<span id="status-text">
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
</span>
)
}
/** 加载指示器 — 打开文件/加载目录/渲染中 */
export 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>
)
}
/** 文档统计 — 行数 / 词数 / 字符数 */
export 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>
</>
)
}
/** 静态项 — 编码 */
export function EncodingItem() {
return <span id="status-encoding">UTF-8</span>
}
/** 静态项 — 语言 */
export function LangItem() {
return <span id="status-lang">Markdown</span>
}
+17 -32
View File
@@ -1,18 +1,25 @@
import React from 'react'
import { FolderOpen, Save, EditMode, PreviewMode, SourceMode, Moon, Sun, Info } from '../Icons'
import type { ViewMode } from '../../types/settings'
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
darkMode: boolean
onToggleDark: () => void
onShowAbout: () => void
isAutoSaving: boolean
autoSaveEnabled: boolean
onToggleAutoSave: () => void
}
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
/**
* 应用顶层工具栏 — 文件操作、自动保存、主题、关于。
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
*/
export const Toolbar = React.memo(function Toolbar({
onOpen, onSave, darkMode, onToggleDark, onShowAbout,
isAutoSaving, autoSaveEnabled, onToggleAutoSave
}: ToolbarProps) {
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作">
@@ -26,34 +33,12 @@ export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, o
</button>
<div className="toolbar-divider" role="separator" />
<button
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
onClick={() => onViewModeChange('editor')}
title="WYSIWYG 编辑 (Ctrl+1)"
aria-label="WYSIWYG 编辑模式"
aria-pressed={viewMode === 'editor'}
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={onToggleAutoSave}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
>
<EditMode size={18} />
<span></span>
</button>
<button
className={`toolbar-btn ${viewMode === 'source' ? 'active' : ''}`}
onClick={() => onViewModeChange('source')}
title="源码编辑 (Ctrl+3)"
aria-label="源码编辑模式"
aria-pressed={viewMode === 'source'}
>
<SourceMode size={18} />
<span></span>
</button>
<button
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
onClick={() => onViewModeChange('preview')}
title="预览 (Ctrl+2)"
aria-label="预览模式"
aria-pressed={viewMode === 'preview'}
>
<PreviewMode size={18} />
<span></span>
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
</button>
</div>
<div className="toolbar-right" role="group" aria-label="设置">