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
+6 -47
View File
@@ -3,7 +3,6 @@ import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
@@ -13,17 +12,10 @@ import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners'
import { useConfirm } from './hooks/useConfirm'
import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
import { useStatusBarItem } from './hooks/useStatusBarItem'
import { useAutoSaveStore } from './stores/autoSaveStore'
import { toggleAutoSaveExternal } from './hooks/useAutoSave'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
import { SourceEditor } from './components/SourceEditor/SourceEditor'
import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
@@ -31,25 +23,6 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
/** 状态栏 — 自动保存开关组件 */
function AutoSaveStatusItem() {
const activeTab = useTabStore(s => s.getActiveTab())
const { isAutoSaving, autoSaveEnabled } = useAutoSaveStore()
if (!activeTab?.filePath) return null
return (
<button
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
onClick={toggleAutoSaveExternal}
>
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
</button>
)
}
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
@@ -61,7 +34,6 @@ export function App() {
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode } = useSettings()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
@@ -70,9 +42,10 @@ export function App() {
useEffect(() => { loadFromDB() }, [loadFromDB])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
useDragDrop()
useFileWatch()
useAutoSave()
// useAutoSave() already called above to get state for toolbar
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
@@ -89,16 +62,6 @@ export function App() {
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners()
// ── 状态栏扩展点架构 ───────────────────────────────────
useDefaultStatusBarItems()
useStatusBarItem({
id: 'statusbar.auto-save',
alignment: 'right',
priority: 200,
Component: AutoSaveStatusItem
})
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
@@ -108,9 +71,6 @@ export function App() {
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
// BUG-10: Don't overwrite editor content the user has modified since the last save.
// The watcher restart after save can trigger a false 'change' event, and re-reading
// the file at that point would replace newer editor content with the just-saved content.
if (tab?.isModified) return
if (!tab) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
@@ -126,9 +86,11 @@ export function App() {
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile} onSave={handleSave}
viewMode={viewMode} onViewModeChange={saveViewMode}
darkMode={darkMode} onToggleDark={toggleDarkMode}
onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
/>
<div id="workspace">
<Sidebar />
@@ -139,16 +101,13 @@ export function App() {
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
{viewMode === 'source' && <div id="editor-panel"><SourceEditor darkMode={darkMode} /></div>}
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
<div id="editor-panel"><Editor darkMode={darkMode} /></div>
</div>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
</div>
</div>
<StatusBar />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
+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="设置">
+6 -9
View File
@@ -1,19 +1,18 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
/**
* 全局键盘快捷键 hook。
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
* 本 hook 仅处理应用级快捷键。
*/
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const setViewMode = useEditorStore(s => s.setViewMode)
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return }
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('source'); return }
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
@@ -28,7 +27,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) {
// mruStack[0] 是最近一个被切走的标签(即上一个活动标签)
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
@@ -36,7 +34,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
return
}
}
// MRU 栈为空或不合法时回退到顺序切换
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = e.shiftKey
? (idx - 1 + tabs.length) % tabs.length
@@ -45,7 +42,7 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
}
return
}
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
}, [handleOpenFile, handleSave, handleSaveAs])
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
-21
View File
@@ -1,21 +0,0 @@
import { useCallback } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ViewMode } from '../types/settings'
/**
* AR-04: 设置 hook
* 不再独立加载设置(由 useSettingsInit 统一加载)
* 仅负责视图模式的读取和保存
*/
export function useSettings() {
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const saveViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode)
settingsRepository.save({ viewMode: mode })
}, [setViewMode])
return { viewMode, saveViewMode }
}
+2 -2
View File
@@ -6,8 +6,8 @@ import { logError } from '../lib/errorHandler'
/**
* AR-04: 统一设置加载 hook
* 一次性从 IndexedDB 加载所有设置,分发到各 store
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式
* 一次性从 IndexedDB 加载所有设置,分发到各 store
* 替代各 hook 各自独立加载设置的模式
*/
export function useSettingsInit() {
const setDarkMode = useEditorStore(s => s.setDarkMode)
-21
View File
@@ -1,21 +0,0 @@
import { useEffect } from 'react'
import { useStatusBarStore, type StatusBarItem } from '../stores/statusBarStore'
/**
* 便捷 hook — 挂载时注册一个状态栏项,卸载时自动清除。
*
* 适合在 App.tsx 或自定义 hook 中注册需要响应式更新的状态栏项。
* 当 item 对象变化时(通过 deps 控制),旧项会被新项替换。
*
* @param item - 状态栏项(含 Component 函数组件)
*/
export function useStatusBarItem(item: StatusBarItem | null): void {
const register = useStatusBarStore(s => s.register)
const unregister = useStatusBarStore(s => s.unregister)
useEffect(() => {
if (!item) return
register(item)
return () => unregister(item.id)
}, [item, register, unregister])
}
-36
View File
@@ -1,36 +0,0 @@
import { useEffect } from 'react'
import { useStatusBarStore } from '../stores/statusBarStore'
import {
FileInfoItem,
LoadingItem,
DocStatsItem,
EncodingItem,
LangItem
} from '../components/StatusBar/StatusBarItems'
/**
* useDefaultStatusBarItems — 注册所有默认状态栏项(不含 auto-save)。
*
* auto-save 项由 App.tsx 单独注册,因为它需要从 autoSaveStore
* 读取状态并通过 toggleAutoSaveExternal() 触发切换。
*/
export function useDefaultStatusBarItems() {
const register = useStatusBarStore(s => s.register)
const unregister = useStatusBarStore(s => s.unregister)
useEffect(() => {
register({ id: 'statusbar.file-info', alignment: 'left', priority: 0, Component: FileInfoItem })
register({ id: 'statusbar.loading', alignment: 'left', priority: 1, Component: LoadingItem })
register({ id: 'statusbar.doc-stats', alignment: 'right', priority: 100, Component: DocStatsItem })
register({ id: 'statusbar.encoding', alignment: 'right', priority: 300, Component: EncodingItem })
register({ id: 'statusbar.lang', alignment: 'right', priority: 400, Component: LangItem })
return () => {
unregister('statusbar.file-info')
unregister('statusbar.loading')
unregister('statusbar.doc-stats')
unregister('statusbar.encoding')
unregister('statusbar.lang')
}
}, [register, unregister])
}
+15
View File
@@ -138,3 +138,18 @@ export async function renderMarkdown(content: string, filePath?: string | null):
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
/**
* 同步版本 — 供 MetonaEditor 的 render 钩子使用。
* 所有 unified 插件均为同步转换器,可以在 render 钩子中同步调用。
*/
export function renderMarkdownSync(content: string, filePath?: string | null): string {
try {
const processor = getCachedProcessor(filePath ?? null)
const result = processor.processSync(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import MeToast from 'metona-toast'
import MeToast from '@metona-team/metona-toast'
/**
* MeToast 全局配置
+7 -7
View File
@@ -1,17 +1,17 @@
import { create } from 'zustand'
import type { EditorView } from '@milkdown/prose/view'
import type { ViewMode } from '../types/settings'
import type { MarkdownEditor } from '@metona-team/metona-editor'
// Module-level getter/setter for the ProseMirror EditorView
// Module-level getter for the MetonaEditor instance
// Used by OutlinePanel for heading navigation
let _getView: (() => EditorView | null) | null = null
let _getEditor: (() => MarkdownEditor | null) | null = null
export function setEditorViewGetter(fn: () => EditorView | null) {
_getView = fn
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
_getEditor = fn
}
export function getEditorView(): EditorView | null {
return _getView ? _getView() : null
export function getMetonaEditor(): MarkdownEditor | null {
return _getEditor ? _getEditor() : null
}
interface EditorState {
-47
View File
@@ -1,47 +0,0 @@
import { create } from 'zustand'
import type { ComponentType } from 'react'
/**
* 状态栏扩展点架构
*
* 功能模块通过 register/unregister 注入自己的状态栏组件,
* StatusBar 只负责按 alignment + priority 排序渲染。
* 避免 StatusBar 随功能膨胀,实现类似 VS Code contributions 的注册模式。
*
* 每个 StatusBarItem 的 Component 是独立的 React 函数组件(无 props),
* 内部使用 hooks 读取所需 store 状态。组件在 StatusBar 的渲染中挂载,
* 因此 hooks 调用在合法的 React 组件上下文中。
*/
export interface StatusBarItem {
id: string
alignment: 'left' | 'right'
/** 同侧排序权重,数字越小越靠左(left)或越靠中(right) */
priority: number
Component: ComponentType
}
interface StatusBarState {
/** 以 id 为 key,方便覆盖和删除 */
items: Record<string, StatusBarItem>
register: (item: StatusBarItem) => void
unregister: (id: string) => void
}
export const useStatusBarStore = create<StatusBarState>((set, get) => ({
items: {},
register: (item: StatusBarItem) => {
const { items } = get()
if (items[item.id]?.Component === item.Component) return
set({ items: { ...items, [item.id]: item } })
},
unregister: (id: string) => {
const { items } = get()
if (!(id in items)) return
const next = { ...items }
delete next[id]
set({ items: next })
}
}))
+9 -428
View File
@@ -121,236 +121,13 @@ body {
overflow: hidden;
}
/* Editor Toolbar */
.editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
overflow-x: auto;
}
.editor-toolbar::-webkit-scrollbar {
height: 0;
}
.toolbar-btn-sm {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 26px;
padding: 0 6px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.toolbar-btn-sm:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.toolbar-divider-sm {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
}
/* Milkdown Wrapper */
.milkdown-wrapper {
/* MetonaEditor Wrapper */
.metona-editor-wrapper {
flex: 1;
overflow: hidden;
height: 100%;
}
.milkdown-wrapper .milkdown {
height: 100%;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text);
background: var(--bg);
}
.milkdown-wrapper .milkdown .editor {
overflow: auto;
height: 100%;
padding: 12px 16px;
outline: none;
user-select: text;
-webkit-user-select: text;
line-height: 1.7;
}
.milkdown-wrapper .milkdown .editor p {
margin: 0.5em 0;
}
.milkdown-wrapper .milkdown .editor h1,
.milkdown-wrapper .milkdown .editor h2,
.milkdown-wrapper .milkdown .editor h3,
.milkdown-wrapper .milkdown .editor h4,
.milkdown-wrapper .milkdown .editor h5,
.milkdown-wrapper .milkdown .editor h6 {
font-family: var(--font-ui);
font-weight: 600;
margin: 1em 0 0.5em;
line-height: 1.3;
color: var(--text);
}
.milkdown-wrapper .milkdown .editor h1 {
font-size: 1.6em;
}
.milkdown-wrapper .milkdown .editor h2 {
font-size: 1.4em;
}
.milkdown-wrapper .milkdown .editor h3 {
font-size: 1.2em;
}
.milkdown-wrapper .milkdown .editor blockquote {
border-left: 3px solid var(--primary);
padding-left: 12px;
margin: 0.5em 0;
color: var(--text-secondary);
}
.milkdown-wrapper .milkdown .editor code {
font-family: var(--font-mono);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.milkdown-wrapper .milkdown .editor pre {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 12px 16px;
overflow-x: auto;
margin: 0.5em 0;
}
.milkdown-wrapper .milkdown .editor pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
.milkdown-wrapper .milkdown .editor ul,
.milkdown-wrapper .milkdown .editor ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.milkdown-wrapper .milkdown .editor li {
margin: 0.25em 0;
}
.milkdown-wrapper .milkdown .editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
.milkdown-wrapper .milkdown .editor a {
color: var(--primary);
text-decoration: none;
}
.milkdown-wrapper .milkdown .editor a:hover {
text-decoration: underline;
}
.milkdown-wrapper .milkdown .editor img {
max-width: 100%;
border-radius: var(--radius);
}
/* ProseMirror selection */
.milkdown-wrapper .milkdown .editor .ProseMirror-selectednode {
outline: 2px solid var(--primary);
border-radius: 2px;
}
.milkdown-wrapper .milkdown .editor ::selection {
background: rgba(26, 115, 232, 0.3);
}
:root.dark .milkdown-wrapper .milkdown .editor ::selection {
background: rgba(100, 160, 255, 0.35);
}
/* ProseMirror cursor */
.milkdown-wrapper .milkdown .editor .ProseMirror-focused {
outline: none;
}
/* Dark mode styles for Milkdown */
.milkdown-wrapper.milkdown-dark .milkdown {
background: var(--bg);
color: var(--text);
}
.milkdown-wrapper.milkdown-dark .milkdown .editor blockquote {
border-left-color: var(--primary);
}
/* Preview Panel */
#preview-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
background: var(--bg);
}
#preview {
padding: 24px 32px;
user-select: text;
cursor: text;
}
/* Source Editor */
.source-editor-wrapper {
flex: 1;
display: flex;
min-width: 0;
min-height: 0;
}
.source-editor-textarea {
flex: 1;
border: none;
outline: none;
resize: none;
padding: 16px 20px;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
tab-size: 2;
color: #1e1e1e;
background: #fafafa;
user-select: text;
cursor: text;
}
.source-editor-dark .source-editor-textarea {
color: #d4d4d4;
background: #1e1e1e;
}
/* Tab Bar */
#tab-bar {
height: 36px;
@@ -571,53 +348,21 @@ body {
color: #ffd54f;
}
/* Status Bar */
#statusbar {
height: var(--statusbar-height);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.status-left,
.status-right {
display: flex;
align-items: center;
gap: 8px;
}
.status-divider {
color: var(--border);
}
.status-stat {
white-space: nowrap;
}
.status-auto-save {
white-space: nowrap;
/* Toolbar auto-save button */
.toolbar-autosave {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
transition: color 0.2s ease;
border: none;
background: transparent;
cursor: pointer;
font-family: var(--font-ui);
padding: 0;
padding: 4px 10px;
}
.status-auto-save:hover {
.toolbar-autosave:hover {
color: var(--primary);
}
.status-auto-save.saving {
color: var(--primary, #1a73e8);
.toolbar-autosave.saving {
color: var(--primary);
animation: auto-save-pulse 1s ease-in-out infinite;
}
@@ -990,14 +735,6 @@ body {
background: var(--primary);
}
/* View Modes */
#app.mode-preview #editor-panel {
display: none;
}
#app.mode-editor #preview-panel {
display: none;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -1321,19 +1058,6 @@ body {
}
}
/* Status bar loading indicator */
.status-loading {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--primary);
font-size: 12px;
}
.status-loading .loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
/* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
@@ -1420,149 +1144,6 @@ button:focus-visible,
background: var(--primary-dark);
}
/* ===== Search & Replace Panel ===== */
.search-replace-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
animation: slideDown 0.15s ease-out;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.search-row {
display: flex;
align-items: center;
gap: 4px;
}
.search-input-group {
flex: 1;
display: flex;
align-items: center;
position: relative;
min-width: 0;
}
.search-input {
flex: 1;
height: 28px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 12px;
font-family: var(--font-ui);
outline: none;
transition: border-color 0.15s;
}
.search-input:focus {
border-color: var(--primary);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
.search-count {
position: absolute;
right: 8px;
font-size: 11px;
color: var(--text-tertiary);
pointer-events: none;
white-space: nowrap;
}
/* D6: 正则语法错误提示 */
.search-input-group .search-count {
color: #c5221f;
}
.search-input-group .search-count:not(:empty) {
/* 仅当有正则错误时变红 */
}
.search-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 6px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.search-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.search-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.search-btn.active {
background: var(--primary-light);
color: var(--primary);
}
.search-close:hover {
background: #e74c3c20;
color: #e74c3c;
}
.replace-btn {
font-size: 11px;
padding: 0 8px;
}
/* Search match highlights */
.search-match-highlight {
background: rgba(255, 213, 0, 0.4);
color: inherit;
border-radius: 2px;
padding: 0;
}
.search-match-active {
background: rgba(255, 150, 0, 0.6);
outline: 1px solid rgba(255, 150, 0, 0.8);
border-radius: 2px;
}
:root.dark .search-match-highlight {
background: rgba(255, 213, 0, 0.25);
}
:root.dark .search-match-active {
background: rgba(255, 180, 0, 0.4);
outline-color: rgba(255, 180, 0, 0.6);
}
/* ===== Outline Panel (Table of Contents) ===== */
.sidebar-outline-section {