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:
+6
-47
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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="设置">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import MeToast from 'metona-toast'
|
||||
import MeToast from '@metona-team/metona-toast'
|
||||
|
||||
/**
|
||||
* MeToast 全局配置
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}))
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user