feat: 阶段1-3 桌面编辑器(Tauri 骨架/编辑核心深化/复杂内容)

This commit is contained in:
Muse
2026-11-13 21:19:23 +08:00
parent cbd7ce4b44
commit cbe641e21a
36 changed files with 10059 additions and 0 deletions

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>new-markdown</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6977
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"dependencies": {
"@milkdown/kit": "7.23.0",
"@tauri-apps/api": "^2.12.4",
"@tauri-apps/plugin-dialog": "^2.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"codemirror": "6.0.2",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language-data": "6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@milkdown/plugin-math": "7.5.9",
"katex": "0.16.11",
"mermaid": "12.3.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.2.0",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.3",
"jsdom": "25.0.1",
"typescript": "5.6.3",
"vite": "5.4.11",
"vitest": "2.1.5"
},
"name": "new-markdown",
"private": true,
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"tauri": "tauri",
"test": "vitest run",
"typecheck": "tsc -b --pretty false"
},
"type": "module",
"version": "0.1.0"
}

489
src/App.tsx Normal file
View File

@@ -0,0 +1,489 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { Editor } from '@milkdown/kit/core'
import TitleBar from './components/TitleBar'
import MenuBar, { type MenuDef } from './components/MenuBar'
import TabBar from './components/TabBar'
import Sidebar from './components/Sidebar'
import StatusBar from './components/StatusBar'
import EditorPane from './components/EditorPane'
import type { SourceEditorApi } from './components/SourceEditor'
// 源码编辑器(CodeMirror)懒加载:不进首屏主包,仅切到源码模式时加载
const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
import { countWords, countChars, extractOutline, baseName, type OutlineItem } from './utils/text'
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
import { editorCommands, insertMermaidBlock, insertMathBlock } from './editor/commands'
import {
isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile,
pickAndReadWebFile, downloadTextFile,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets,
} from './services/platform'
const WELCOME_MD = `# 欢迎使用 new-markdown
这是阶段 1 的桌面骨架:标题栏、菜单、标签页、侧边栏、状态栏与编辑器已经接通,**打开 → 编辑 → 保存**全链路可用。
## 现在可以试
- 文件菜单:新建、打开文件、打开文件夹、保存、另存为
- 段落/格式菜单与快捷键:Ctrl+1~6 标题、Ctrl+B 加粗、Ctrl+Shift+Q 引用
- 左侧大纲:点击标题定位;文件页签需要先打开文件夹
- 视图菜单:源码模式、深色主题、侧边栏开关、缩放
试着输入 \`# \` 加空格,或 \`- [ ] \` 开始一个任务列表。
`
const RECENT_KEY = 'new-markdown:recent'
const UI_KEY = 'new-markdown:ui'
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
}
function loadUi(): { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number } {
try {
return { theme: 'light', sidebar: true, sidebarWidth: 250, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
} catch { return { theme: 'light', sidebar: true, sidebarWidth: 250 } }
}
function toDiskContent(content: string, lineEnding: string): string {
return lineEnding === 'CRLF' ? content.replace(/\n/g, '\r\n') : content
}
export default function App() {
const [state, dispatch] = useReducer(tabsReducer, undefined, (): TabsState => {
const welcome = createTab({ title: '欢迎', content: WELCOME_MD })
return { tabs: [welcome], activeId: welcome.id }
})
const tab = activeTab(state)
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline'>('outline')
const [recent, setRecent] = useState<string[]>(loadRecent)
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
const [sourceMode, setSourceMode] = useState(false)
const [editorEpoch, setEditorEpoch] = useState(0)
const [zoom, setZoom] = useState(1)
const [ui, setUi] = useState(loadUi)
const [modal, setModal] = useState<'about' | 'shortcuts' | 'link' | null>(null)
const [error, setError] = useState<string | null>(null)
const [pendingClose, setPendingClose] = useState<{ ids: string[]; after: () => void } | null>(null)
const editorRef = useRef<Editor | null>(null)
const jumpRef = useRef<(text: string) => void>(() => undefined)
const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null)
const sourceApiRef = useRef<SourceEditorApi | null>(null)
const pendingAnchorRef = useRef<CursorAnchor | null>(null)
const [sourceCursorOffset, setSourceCursorOffset] = useState(0)
const tauriMode = isTauri()
useEffect(() => {
document.documentElement.dataset.theme = ui.theme
localStorage.setItem(UI_KEY, JSON.stringify(ui))
}, [ui])
useEffect(() => {
localStorage.setItem(RECENT_KEY, JSON.stringify(recent))
}, [recent])
const pushRecent = useCallback((path: string) => {
setRecent((prev) => [path, ...prev.filter((p) => p !== path)].slice(0, 10))
}, [])
// ——— 文件动作 ———
const openPath = useCallback(async (path: string) => {
try {
const file = await readTextFile(path)
dispatch({
type: 'open',
tab: createTab({
path, title: baseName(path), content: file.content,
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
}),
})
pushRecent(path)
} catch (e) { setError(String(e)) }
}, [pushRecent])
const newDoc = useCallback(() => {
dispatch({ type: 'open', tab: createTab({ title: '未命名', content: '' }) })
setSourceMode(false)
}, [])
const openFile = useCallback(async () => {
if (tauriMode) {
const path = await pickOpenFile()
if (path) await openPath(path)
} else {
const picked = await pickAndReadWebFile()
if (picked) {
dispatch({ type: 'open', tab: createTab({ title: picked.name.replace(/\.(md|markdown|txt)$/i, ''), content: picked.content }) })
}
}
}, [tauriMode, openPath])
const openFolder = useCallback(async () => {
const folder = await pickFolder()
if (folder) {
setWorkspaceRoot(folder)
setSidebarMode('files')
setUi((u) => ({ ...u, sidebar: true }))
}
}, [])
const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise<boolean> => {
try {
if (!tauriMode) {
downloadTextFile(`${target.title}.md`, target.content)
dispatch({ type: 'saved', id: target.id })
return true
}
let path = target.path
if (!path || saveAs) {
path = await pickSaveFile(path ?? `${target.title}.md`)
if (!path) return false
}
await writeTextFile(path, toDiskContent(target.content, target.lineEnding), target.hadBom)
dispatch({ type: 'saved', id: target.id, path, title: baseName(path) })
pushRecent(path)
return true
} catch (e) {
setError(String(e))
return false
}
}, [tauriMode, pushRecent])
const requestClose = useCallback((ids: string[], after: () => void) => {
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t))
if (dirtyOnes.length === 0) { after(); return }
setPendingClose({ ids, after })
}, [state.tabs])
const closeTab = useCallback((id: string) => {
requestClose([id], () => dispatch({ type: 'close', id }))
}, [requestClose])
// ——— 编辑器动作 ———
const withEditor = useCallback((fn: (e: Editor) => void) => {
if (editorRef.current) { fn(editorRef.current); focusEditor(editorRef.current) }
}, [])
const toggleSourceMode = useCallback(() => {
if (!tab) return
if (!sourceMode) {
// 进入源码:把渲染光标锚点换算为源码偏移
const anchor = anchorApiRef.current?.getAnchor()
setSourceCursorOffset(anchor ? offsetInMarkdown(tab.content, anchor) : 0)
setSourceMode(true)
} else {
// 退出源码:记住源码光标锚点,编辑器重建后回定位
pendingAnchorRef.current = sourceApiRef.current?.getAnchor() ?? null
setSourceMode(false)
setEditorEpoch((n) => n + 1) // 以源码文本重建编辑器模型
}
}, [tab, sourceMode])
const jumpHeading = useCallback((item: OutlineItem) => {
if (sourceMode) sourceApiRef.current?.jumpToLine(item.line)
else jumpRef.current(item.text)
}, [sourceMode])
// ——— 全局快捷键(编辑器内部快捷键如 Ctrl+B 由 Milkdown keymap 处理,菜单点击走命令) ———
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫)
const mod = e.ctrlKey || e.metaKey
if (!mod) return
const key = e.key.toLowerCase()
const inTextField = (e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT'
if (key === 'n' && !inTextField) { e.preventDefault(); newDoc() }
else if (key === 'o' && !inTextField) { e.preventDefault(); void openFile() }
else if (key === 's' && tab) { e.preventDefault(); void saveTab(tab, e.shiftKey) }
else if (key === 'w' && tab) { e.preventDefault(); closeTab(tab.id) }
else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) }
else if (key === 'k' && !e.shiftKey && !inTextField) { e.preventDefault(); setModal('link') }
else if (key === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) }
else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() }
else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) }
else if (key === 'k' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.codeBlock) }
else if ((key === '=' || key === '+')) { e.preventDefault(); setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) }
else if (key === '-') { e.preventDefault(); setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) }
else if (/^[0-6]$/.test(key) && !inTextField) {
e.preventDefault()
const level = Number(key)
withEditor((ed) => (level === 0 ? editorCommands.paragraph(ed) : editorCommands.heading(ed, level)))
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [tab, newDoc, openFile, saveTab, closeTab, withEditor])
const pasteFromClipboard = useCallback(async () => {
if (sourceMode) {
// 源码模式下 CodeMirror 自带粘贴处理,聚焦后由浏览器原生粘贴完成
sourceApiRef.current?.focus()
document.execCommand('paste')
return
}
try {
const text = await navigator.clipboard.readText()
if (text) { withEditor((e) => insertPlainText(e, text)); return }
} catch { /* 权限拒绝时退回 execCommand */ }
document.execCommand('paste')
}, [sourceMode, withEditor])
const stateRef = useRef(state)
stateRef.current = state
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
const handleImageFile = useCallback(async (file: File): Promise<string> => {
const current = activeTab(stateRef.current)
if (isTauri() && current?.path) {
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg')
const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}`
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()))
return saveBytesToAssets(current.path, named, bytes)
}
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => resolve(URL.createObjectURL(file))
reader.readAsDataURL(file)
})
}, [])
const insertImage = useCallback(async () => {
if (!tab) return
if (tauriMode) {
if (!tab.path) { setError('请先保存文档,再插入本地图片(图片会复制到文档同名 .assets 目录并以相对路径引用)。'); return }
const picked = await pickImageFile()
if (!picked) return
try {
const rel = await copyToAssets(picked, tab.path)
withEditor((e) => editorCommands.image(e, rel, baseName(picked)))
} catch (e) { setError(String(e)) }
} else {
const picked = await pickAndReadWebImage()
if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name))
}
}, [tab, tauriMode, withEditor])
const insertAttachment = useCallback(async () => {
if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return }
const picked = await pickAnyFile()
if (!picked) return
try {
const rel = await copyToAssets(picked, tab.path)
withEditor((e) => insertLinkText(e, baseName(picked), rel))
} catch (e) { setError(String(e)) }
}, [tab, withEditor])
const outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab])
const menus: MenuDef[] = useMemo(() => [
{
label: '文件',
items: [
{ label: '新建', shortcut: 'Ctrl+N', onClick: newDoc },
{ label: '打开文件…', shortcut: 'Ctrl+O', onClick: () => void openFile() },
{ label: '打开文件夹…', disabled: !tauriMode, onClick: () => void openFolder() },
{ divider: true },
...recent.slice(0, 8).map((p) => ({ label: baseName(p), onClick: () => void openPath(p) })),
...(recent.length ? [{ divider: true } as const] : []),
{ label: '保存', shortcut: 'Ctrl+S', disabled: !tab, onClick: () => tab && void saveTab(tab, false) },
{ label: '另存为…', shortcut: 'Ctrl+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) },
{ label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) },
],
},
{
label: '编辑',
items: [
{ label: '撤销', shortcut: 'Ctrl+Z', disabled: sourceMode, onClick: () => withEditor(callUndo) },
{ label: '重做', shortcut: 'Ctrl+Y', disabled: sourceMode, onClick: () => withEditor(callRedo) },
{ divider: true },
{ label: '剪切', onClick: () => document.execCommand('cut') },
{ label: '复制', onClick: () => document.execCommand('copy') },
{ label: '粘贴', onClick: () => { void pasteFromClipboard() } },
{ label: '全选', onClick: () => document.execCommand('selectAll') },
],
},
{
label: '段落',
items: [
{ label: '段落', shortcut: 'Ctrl+0', disabled: sourceMode, onClick: () => withEditor(editorCommands.paragraph) },
...[1, 2, 3, 4, 5, 6].map((lv) => ({
label: `${'一二三四五六'[lv - 1]}级标题`, shortcut: `Ctrl+${lv}`, disabled: sourceMode,
onClick: () => withEditor((e) => editorCommands.heading(e, lv)),
})),
{ divider: true },
{ label: '引用', shortcut: 'Ctrl+Shift+Q', disabled: sourceMode, onClick: () => withEditor(editorCommands.blockquote) },
{ label: '无序列表', disabled: sourceMode, onClick: () => withEditor(editorCommands.bulletList) },
{ label: '有序列表', disabled: sourceMode, onClick: () => withEditor(editorCommands.orderedList) },
{ label: '代码块', shortcut: 'Ctrl+Shift+K', disabled: sourceMode, onClick: () => withEditor(editorCommands.codeBlock) },
{ label: '表格(3×3)', shortcut: 'Ctrl+T', disabled: sourceMode, onClick: () => withEditor((e) => editorCommands.insertTable(e)) },
{ label: '数学公式块', disabled: sourceMode, onClick: () => withEditor(insertMathBlock) },
{ label: 'Mermaid 图表', disabled: sourceMode, onClick: () => withEditor(insertMermaidBlock) },
{ label: '水平分割线', disabled: sourceMode, onClick: () => withEditor(editorCommands.hr) },
],
},
{
label: '格式',
items: [
{ label: '加粗', shortcut: 'Ctrl+B', disabled: sourceMode, onClick: () => withEditor(editorCommands.bold) },
{ label: '斜体', shortcut: 'Ctrl+I', disabled: sourceMode, onClick: () => withEditor(editorCommands.italic) },
{ label: '删除线', disabled: sourceMode, onClick: () => withEditor(editorCommands.strikethrough) },
{ label: '行内代码', disabled: sourceMode, onClick: () => withEditor(editorCommands.inlineCode) },
{ label: '超链接…', shortcut: 'Ctrl+K', disabled: sourceMode, onClick: () => setModal('link') },
{ label: '图片…', shortcut: 'Ctrl+Shift+I', disabled: sourceMode, onClick: () => { void insertImage() } },
{ label: '附件…', disabled: sourceMode || !tauriMode, onClick: () => { void insertAttachment() } },
],
},
{
label: '视图',
items: [
{ label: '显示侧边栏', shortcut: 'Ctrl+Shift+L', checked: ui.sidebar, onClick: () => setUi((u) => ({ ...u, sidebar: !u.sidebar })) },
{ label: '文件列表', checked: sidebarMode === 'files', onClick: () => { setSidebarMode('files'); setUi((u) => ({ ...u, sidebar: true })) } },
{ label: '文档大纲', checked: sidebarMode === 'outline', onClick: () => { setSidebarMode('outline'); setUi((u) => ({ ...u, sidebar: true })) } },
{ divider: true },
{ label: '源代码模式', checked: sourceMode, disabled: !tab, onClick: toggleSourceMode },
{ label: '深色主题', checked: ui.theme === 'dark', onClick: () => setUi((u) => ({ ...u, theme: u.theme === 'dark' ? 'light' : 'dark' })) },
{ divider: true },
{ label: '放大', shortcut: 'Ctrl+=', onClick: () => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) },
{ label: '缩小', shortcut: 'Ctrl+-', onClick: () => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) },
{ label: '恢复默认缩放', onClick: () => setZoom(1) },
],
},
{
label: '帮助',
items: [
{ label: '快捷键说明', onClick: () => setModal('shortcuts') },
{ label: '关于 new-markdown', onClick: () => setModal('about') },
],
},
], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard])
const pendingTitles = pendingClose
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
: ''
return (
<div className="app">
<TitleBar docTitle={tab?.title ?? 'new-markdown'} dirty={tab ? isDirty(tab) : false} />
<MenuBar menus={menus} />
<TabBar
tabs={state.tabs}
activeId={state.activeId}
onActivate={(id) => { dispatch({ type: 'activate', id }); setSourceMode(false) }}
onClose={closeTab}
onCloseOthers={(id) => requestClose(state.tabs.filter((t) => t.id !== id).map((t) => t.id), () => dispatch({ type: 'closeOthers', id }))}
onCloseRight={(id) => {
const idx = state.tabs.findIndex((t) => t.id === id)
requestClose(state.tabs.slice(idx + 1).map((t) => t.id), () => dispatch({ type: 'closeRight', id }))
}}
onNew={newDoc}
onMove={(id, toIndex) => dispatch({ type: 'move', id, toIndex })}
/>
<div className="main">
{ui.sidebar && (
<div style={{ width: ui.sidebarWidth, flex: 'none', display: 'flex' }}>
<Sidebar
mode={sidebarMode}
onModeChange={setSidebarMode}
workspaceRoot={workspaceRoot}
activePath={tab?.path ?? null}
onOpenFile={(path) => void openPath(path)}
onOpenFolder={() => void openFolder()}
outline={outline}
onJumpHeading={jumpHeading}
tauriMode={tauriMode}
/>
</div>
)}
<section className="editor-scroll" style={{ ['--doc-zoom' as string]: zoom }}>
{!tab ? (
<div className="empty-state">
<h2>没有打开的文档</h2>
<p>用 Ctrl+N 新建,或 Ctrl+O 打开一个 Markdown 文件。</p>
<div>
<button className="primary" onClick={newDoc}>新建文档</button>
<button onClick={() => void openFile()}>打开文件…</button>
</div>
</div>
) : sourceMode ? (
<Suspense fallback={<div className="empty-state">正在加载源码编辑器…</div>}>
<SourceEditor
key={`source:${tab.id}:${ui.theme}`}
tabId={tab.id}
initialText={tab.content}
initialCursorOffset={sourceCursorOffset}
dark={ui.theme === 'dark'}
onChange={(text) => dispatch({ type: 'updateContent', id: tab.id, content: text })}
onReady={(api) => { sourceApiRef.current = api }}
/>
</Suspense>
) : (
<EditorPane
key={`${tab.id}:${editorEpoch}`}
tabId={tab.id}
initialMarkdown={tab.content}
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
onCursor={setCursor}
onImageFile={handleImageFile}
onReady={(api) => {
editorRef.current = api.editor
jumpRef.current = api.jumpToHeading
anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
if (api.editor && pendingAnchorRef.current) {
api.setCursorByAnchor(pendingAnchorRef.current)
pendingAnchorRef.current = null
}
}}
/>
)}
</section>
</div>
<StatusBar
words={tab ? countWords(tab.content) : 0}
chars={tab ? countChars(tab.content) : 0}
lines={tab ? tab.content.split('\n').length : 0}
cursor={cursor}
encoding={tab?.encoding ?? 'UTF-8'}
lineEnding={tab?.lineEnding ?? 'LF'}
dirty={tab ? isDirty(tab) : false}
sourceMode={sourceMode}
tauriMode={tauriMode}
zoom={zoom}
/>
{pendingClose && (
<ConfirmSaveModal
title={pendingTitles}
onCancel={() => setPendingClose(null)}
onDiscard={() => { const { after } = pendingClose; setPendingClose(null); after() }}
onSave={async () => {
const targets = state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t))
for (const t of targets) {
const target = t.id === tab?.id && editorRef.current ? { ...t, content: t.content } : t
const ok = await saveTab(target, false)
if (!ok) return
}
const { after } = pendingClose
setPendingClose(null)
after()
}}
/>
)}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'shortcuts' && <ShortcutsModal onClose={() => setModal(null)} />}
{modal === 'link' && (
<LinkModal
onCancel={() => setModal(null)}
onSubmit={(href) => { setModal(null); if (href) withEditor((e) => editorCommands.link(e, href)) }}
/>
)}
{error && <ErrorModal message={error} onClose={() => setError(null)} />}
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import TableToolbar from './TableToolbar'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor'
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
export interface EditorPaneApi {
editor: Editor | null
jumpToHeading: (text: string) => void
getAnchor: () => CursorAnchor | null
setCursorByAnchor: (anchor: CursorAnchor) => void
}
interface Props {
tabId: string
initialMarkdown: string
onMarkdown: (tabId: string, markdown: string) => void
onCursor: (cursor: CursorInfo) => void
onReady: (api: EditorPaneApi) => void
onImageFile?: (file: File) => Promise<string>
}
const EMPTY_API: EditorPaneApi = {
editor: null,
jumpToHeading: () => undefined,
getAnchor: () => null,
setCursorByAnchor: () => undefined,
}
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile }: Props) {
const wrapRef = useRef<HTMLDivElement>(null)
const editorObjRef = useRef<Editor | null>(null)
const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null)
const tableElRef = useRef<HTMLElement | null>(null)
const updateTableBar = useCallback(() => {
const wrap = wrapRef.current
const el = tableElRef.current
if (!wrap || !el) { setTableBar(null); return }
const wr = wrap.getBoundingClientRect()
const tr = el.getBoundingClientRect()
setTableBar({ top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) })
}, [])
const hostRef = useRef<HTMLDivElement>(null)
const initialRef = useRef(initialMarkdown)
const callbacksRef = useRef({ onMarkdown, onCursor, onReady, onImageFile })
callbacksRef.current = { onMarkdown, onCursor, onReady, onImageFile }
useEffect(() => {
if (!hostRef.current) return
const editor = createMarkdownEditor(hostRef.current, initialRef.current, {
onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md),
onCursor: (c) => callbacksRef.current.onCursor(c),
onTableContext: (inTable, el) => { tableElRef.current = el; if (inTable) updateTableBar(); else setTableBar(null) },
}, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) })
editorObjRef.current = editor
const jumpToHeading = (text: string) => {
const els = hostRef.current?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? []
for (const el of els) {
if (el.textContent?.trim() === text) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
break
}
}
}
const getAnchor = (): CursorAnchor | null => {
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const $from = view.state.selection.$from
return { blockText: $from.parent.textContent, offsetInBlock: $from.parentOffset }
})
} catch { return null }
}
const setCursorByAnchor = (anchor: CursorAnchor) => {
try {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const doc = view.state.doc
const blocks: { text: string; start: number; size: number }[] = []
doc.descendants((node, pos) => {
if (node.isTextblock) blocks.push({ text: node.textContent, start: pos + 1, size: node.content.size })
return true
})
const idx = findBlockIndex(blocks.map((b) => b.text), anchor)
if (idx < 0) return
const target = blocks[idx]
const pos = target.start + Math.max(0, Math.min(anchor.offsetInBlock, target.size))
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)).scrollIntoView())
view.focus()
})
} catch { /* 定位失败静默:不打断模式切换 */ }
}
editor.create()
.then(() => callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor }))
.catch((e) => console.error('编辑器创建失败', e))
const scrollParent = wrapRef.current?.closest('.editor-scroll')
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
scrollParent?.addEventListener('scroll', onScroll, { passive: true })
return () => {
scrollParent?.removeEventListener('scroll', onScroll)
editorObjRef.current = null
setTableBar(null)
try {
callbacksRef.current.onMarkdown(tabId, getMarkdown(editor))
} catch { /* 编辑器未就绪时跳过回写 */ }
callbacksRef.current.onReady(EMPTY_API)
editor.destroy().catch(() => undefined)
}
}, [tabId, updateTableBar])
return (
<div className="editor-pane-wrap" ref={wrapRef}>
{tableBar && editorObjRef.current && (
<TableToolbar editor={editorObjRef.current} top={tableBar.top} left={tableBar.left} />
)}
<div className="editor-host" ref={hostRef} />
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from 'react'
export interface MenuItemDef {
label?: string
shortcut?: string
checked?: boolean
disabled?: boolean
divider?: boolean
onClick?: () => void
}
export interface MenuDef {
label: string
items: MenuItemDef[]
}
/** 自绘菜单栏:菜单数据由 App 注入,全部条目均为真实动作(无占位项)。
* 支持点击展开、悬停切换、Esc/点击外部关闭。 */
export default function MenuBar({ menus }: { menus: MenuDef[] }) {
const [open, setOpen] = useState<string | null>(null)
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(null)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(null) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
}
}, [])
return (
<nav className="menubar" ref={ref}>
{menus.map((menu) => (
<div key={menu.label} className="menu">
<button
className={`menu-title${open === menu.label ? ' open' : ''}`}
onClick={() => setOpen(open === menu.label ? null : menu.label)}
onMouseEnter={() => { if (open) setOpen(menu.label) }}
>
{menu.label}
</button>
{open === menu.label && (
<div className="menu-dropdown">
{menu.items.map((item, i) =>
item.divider ? (
<div key={i} className="menu-divider" />
) : (
<button
key={i}
className="menu-item"
disabled={item.disabled}
onClick={() => { setOpen(null); item.onClick?.() }}
>
<span className="menu-check">{item.checked ? '✓' : ''}</span>
<span className="menu-label">{item.label}</span>
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
</button>
),
)}
</div>
)}
</div>
))}
</nav>
)
}

96
src/components/Modals.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useState } from 'react'
function Shell({ children, onCancel }: { children: React.ReactNode; onCancel?: () => void }) {
return (
<div className="modal-backdrop" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()}>{children}</div>
</div>
)
}
export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: {
title: string
onSave: () => void
onDiscard: () => void
onCancel: () => void
}) {
return (
<Shell onCancel={onCancel}>
<h3>保存修改?</h3>
<p>「{title}」有未保存的修改,关闭前是否保存?</p>
<div className="modal-actions">
<button onClick={onCancel}>取消</button>
<button onClick={onDiscard}>不保存</button>
<button className="primary" onClick={onSave}>保存</button>
</div>
</Shell>
)
}
export function AboutModal({ onClose }: { onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>new-markdown</h3>
<p>Windows Markdown 所见即所得编辑器 · 版本 0.1.0(阶段 1)</p>
<p>内核:Milkdown 7.23(ProseMirror)· 桌面:Tauri 2</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>关闭</button></div>
</Shell>
)
}
const SHORTCUTS: [string, string][] = [
['新建文档', 'Ctrl+N'], ['打开文件', 'Ctrl+O'], ['保存', 'Ctrl+S'], ['另存为', 'Ctrl+Shift+S'],
['关闭标签页', 'Ctrl+W'], ['撤销 / 重做', 'Ctrl+Z / Ctrl+Y'],
['加粗 / 斜体', 'Ctrl+B / Ctrl+I'], ['插入链接', 'Ctrl+K'],
['段落 / 一级标题', 'Ctrl+0 / Ctrl+1'], ['二级 ~ 六级标题', 'Ctrl+2 ~ Ctrl+6'],
['引用', 'Ctrl+Shift+Q'], ['代码块', 'Ctrl+Shift+K'],
['显示/隐藏侧边栏', 'Ctrl+Shift+L'], ['放大 / 缩小', 'Ctrl+= / Ctrl+-'],
]
export function ShortcutsModal({ onClose }: { onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>快捷键</h3>
<table className="shortcuts-table">
<tbody>
{SHORTCUTS.map(([name, key]) => (
<tr key={name}><td>{name}</td><td><kbd>{key}</kbd></td></tr>
))}
</tbody>
</table>
<p className="modal-note">需求表里 Ctrl+0 同时挂了「段落」与「恢复默认缩放」,本实现取「段落」(与 Typora 一致),恢复缩放走视图菜单,避免同键双义。阶段 6 设置中心提供快捷键自定义与冲突检测。</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>关闭</button></div>
</Shell>
)
}
export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) => void; onCancel: () => void }) {
const [href, setHref] = useState('https://')
return (
<Shell onCancel={onCancel}>
<h3>插入链接</h3>
<input
className="modal-input"
value={href}
autoFocus
onChange={(e) => setHref(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onSubmit(href.trim()) }}
placeholder="https://example.com"
/>
<div className="modal-actions">
<button onClick={onCancel}>取消</button>
<button className="primary" onClick={() => onSubmit(href.trim())}>确定</button>
</div>
</Shell>
)
}
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>操作失败</h3>
<p>{message}</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>知道了</button></div>
</Shell>
)
}

121
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from 'react'
import { listDir, type DirEntry } from '../services/platform'
import type { OutlineItem } from '../utils/text'
import { baseName } from '../utils/text'
interface Props {
mode: 'files' | 'outline'
onModeChange: (mode: 'files' | 'outline') => void
workspaceRoot: string | null
activePath: string | null
onOpenFile: (path: string) => void
onOpenFolder: () => void
outline: OutlineItem[]
onJumpHeading: (item: OutlineItem) => void
tauriMode: boolean
}
export default function Sidebar(p: Props) {
return (
<aside className="sidebar">
<div className="sidebar-tabs">
<button className={p.mode === 'files' ? 'active' : ''} onClick={() => p.onModeChange('files')}>文件</button>
<button className={p.mode === 'outline' ? 'active' : ''} onClick={() => p.onModeChange('outline')}>大纲</button>
</div>
{p.mode === 'outline' ? (
<div className="outline">
{p.outline.length === 0 && <div className="sidebar-empty">当前文档暂无标题</div>}
{p.outline.map((o, i) => (
<button
key={i}
className="outline-item"
style={{ paddingLeft: 10 + (o.level - 1) * 13 }}
onClick={() => p.onJumpHeading(o)}
title={o.text}
>
{o.text}
</button>
))}
</div>
) : p.workspaceRoot ? (
<FileTree root={p.workspaceRoot} activePath={p.activePath} onOpenFile={p.onOpenFile} />
) : (
<div className="sidebar-empty">
<p>尚未打开文件夹。</p>
{p.tauriMode
? <button className="sidebar-action" onClick={p.onOpenFolder}>打开文件夹…</button>
: <p>Web 降级模式不支持文件夹浏览,请用「文件 → 打开文件」。</p>}
</div>
)}
</aside>
)
}
function FileTree({ root, activePath, onOpenFile }: { root: string; activePath: string | null; onOpenFile: (path: string) => void }) {
const [refreshKey, setRefreshKey] = useState(0)
return (
<div className="filetree">
<div className="filetree-head">
<span className="filetree-root" title={root}>{baseName(root)}</span>
<button aria-label="刷新目录" onClick={() => setRefreshKey((k) => k + 1)}>⟳</button>
</div>
<TreeLevel dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} />
</div>
)
}
function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey }: {
dir: string
depth: number
activePath: string | null
onOpenFile: (path: string) => void
refreshKey: number
}) {
const [entries, setEntries] = useState<DirEntry[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const load = useCallback(async () => {
try {
setEntries(await listDir(dir))
setError(null)
} catch (e) {
setError(String(e))
}
}, [dir])
useEffect(() => { void load() }, [load, refreshKey])
if (error) return <div className="sidebar-empty" style={{ paddingLeft: depth * 12 }}>{error}</div>
if (!entries) return <div className="sidebar-empty" style={{ paddingLeft: depth * 12 }}>加载中…</div>
const toggle = (path: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(path)) next.delete(path)
else next.add(path)
return next
})
}
return (
<div>
{entries.map((entry) => (
<div key={entry.path}>
<button
className={`tree-item${!entry.isDir && entry.path === activePath ? ' active' : ''}`}
style={{ paddingLeft: 8 + depth * 13 }}
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
title={entry.path}
>
<span className="tree-icon">{entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'}</span>
{entry.name}
</button>
{entry.isDir && expanded.has(entry.path) && (
<TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} />
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { languages } from '@codemirror/language-data'
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language'
import { oneDark } from '@codemirror/theme-one-dark'
import { anchorFromMarkdown, type CursorAnchor } from '../editor/cursorSync'
export interface SourceEditorApi {
jumpToLine: (line: number) => void
getAnchor: () => CursorAnchor | null
focus: () => void
}
interface Props {
tabId: string
initialText: string
initialCursorOffset: number
dark: boolean
onChange: (text: string) => void
onReady: (api: SourceEditorApi) => void
}
/** 源码模式编辑器(CodeMirror 6):行号、Markdown 高亮、历史栈与渲染模式相互独立,进入/退出靠锚点同步光标。 */
export default function SourceEditor({ tabId, initialText, initialCursorOffset, dark, onChange, onReady }: Props) {
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const initialRef = useRef({ initialText, initialCursorOffset })
const callbacksRef = useRef({ onChange, onReady })
callbacksRef.current = { onChange, onReady }
useEffect(() => {
if (!hostRef.current) return
const { initialText: doc, initialCursorOffset: cursor } = initialRef.current
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc,
selection: { anchor: Math.max(0, Math.min(cursor, doc.length)) },
extensions: [
lineNumbers(),
highlightActiveLine(),
drawSelection(),
history(),
indentOnInput(),
bracketMatching(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
markdown({ codeLanguages: languages }),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacksRef.current.onChange(update.state.doc.toString())
}),
EditorView.theme({
'&': { height: '100%', fontSize: '14px', backgroundColor: 'transparent' },
'.cm-content': { fontFamily: '"Cascadia Code", Consolas, monospace', padding: '24px 0', lineHeight: '1.7' },
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
}),
...(dark ? [oneDark] : []),
],
}),
})
viewRef.current = view
view.focus()
callbacksRef.current.onReady({
jumpToLine: (line) => {
const target = Math.max(1, Math.min(line + 1, view.state.doc.lines))
const pos = view.state.doc.line(target).from
view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
view.focus()
},
getAnchor: () => anchorFromMarkdown(view.state.doc.toString(), view.state.selection.main.head),
focus: () => view.focus(),
})
return () => {
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入
callbacksRef.current.onChange(view.state.doc.toString())
view.destroy()
viewRef.current = null
}
// dark 变化时整组件由父级 key 重挂载处理,这里只随 tabId 建/销
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId])
return <div className="source-cm" ref={hostRef} />
}

View File

@@ -0,0 +1,33 @@
import type { CursorInfo } from '../editor/editor'
interface Props {
words: number
chars: number
lines: number
cursor: CursorInfo
encoding: string
lineEnding: string
dirty: boolean
sourceMode: boolean
tauriMode: boolean
zoom: number
}
export default function StatusBar(p: Props) {
return (
<footer className="statusbar">
<span>字数:{p.words}</span>
<span>字符:{p.chars}</span>
<span>行:{p.cursor.line},列:{p.cursor.col}</span>
<span>共 {p.lines} 行</span>
<span>{p.encoding}</span>
<span>{p.lineEnding}</span>
<span>{p.dirty ? '未保存' : '已保存'}</span>
<span>{p.sourceMode ? '源代码模式' : '即时渲染'}</span>
<span className="status-right">
<span>{Math.round(p.zoom * 100)}%</span>
<span>{p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'}</span>
</span>
</footer>
)
}

64
src/components/TabBar.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { useState } from 'react'
import type { DocTab } from '../stores/tabs'
import { isDirty } from '../stores/tabs'
interface Props {
tabs: DocTab[]
activeId: string | null
onActivate: (id: string) => void
onClose: (id: string) => void
onCloseOthers: (id: string) => void
onCloseRight: (id: string) => void
onNew: () => void
onMove: (id: string, toIndex: number) => void
}
export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove }: Props) {
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
const [dragId, setDragId] = useState<string | null>(null)
if (tabs.length === 0) return null
return (
<div className="tabbar" onClick={() => setMenuFor(null)}>
{tabs.map((tab, index) => (
<div
key={tab.id}
className={`tab${tab.id === activeId ? ' active' : ''}`}
draggable
onDragStart={() => setDragId(tab.id)}
onDragOver={(e) => {
e.preventDefault()
if (dragId && dragId !== tab.id) {
onMove(dragId, index)
}
}}
onDragEnd={() => setDragId(null)}
onClick={() => onActivate(tab.id)}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuFor({ id: tab.id, x: e.clientX, y: e.clientY })
}}
title={tab.path ?? tab.title}
>
<span className="tab-title">{tab.title}</span>
{isDirty(tab) && <span className="tab-dirty" title="未保存">●</span>}
<button
className="tab-close"
aria-label={`关闭 ${tab.title}`}
onClick={(e) => { e.stopPropagation(); onClose(tab.id) }}
>×</button>
</div>
))}
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
{menuFor && (
<div className="context-menu" style={{ left: menuFor.x, top: menuFor.y }}>
<button onClick={() => onClose(menuFor.id)}>关闭</button>
<button onClick={() => onCloseOthers(menuFor.id)}>关闭其他标签页</button>
<button onClick={() => onCloseRight(menuFor.id)}>关闭右侧标签页</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import type { Editor } from '@milkdown/kit/core'
import { editorCommands } from '../editor/commands'
interface Props {
editor: Editor
top: number
left: number
}
/** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */
export default function TableToolbar({ editor, top, left }: Props) {
const btn = (label: string, fn: () => void, title?: string) => (
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} title={title ?? label}>{label}</button>
)
return (
<div className="table-toolbar" style={{ top, left }} role="toolbar" aria-label="表格工具">
{btn('+行', () => editorCommands.addRowAfter(editor), '在下方新增行')}
{btn('+列', () => editorCommands.addColAfter(editor), '在右侧新增列')}
{btn('删行', () => editorCommands.deleteRow(editor), '删除当前行')}
{btn('删列', () => editorCommands.deleteCol(editor), '删除当前列')}
<span className="tt-sep" />
{btn('左齐', () => editorCommands.setAlign(editor, 'left'), '列左对齐')}
{btn('居中', () => editorCommands.setAlign(editor, 'center'), '列居中')}
{btn('右齐', () => editorCommands.setAlign(editor, 'right'), '列右对齐')}
<span className="tt-sep" />
{btn('删表', () => editorCommands.deleteTable(editor), '删除整个表格')}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { isTauri } from '../services/platform'
interface Props {
docTitle: string
dirty: boolean
}
/** 自定义标题栏(Tauri decorations=false):拖动区 + 文档名 + 窗口控制。
* Web 降级模式下不渲染窗口控制(浏览器自有边框),避免假按钮。 */
export default function TitleBar({ docTitle, dirty }: Props) {
const tauri = isTauri()
const winAction = async (action: 'min' | 'max' | 'close') => {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
if (action === 'min') await win.minimize()
else if (action === 'close') await win.close()
else {
const maximized = await win.isMaximized()
if (maximized) await win.unmaximize()
else await win.maximize()
}
}
return (
<header className="titlebar" data-tauri-drag-region>
<span className="app-mark">M</span>
<span className="titlebar-name">
{docTitle}
{dirty && <span className="dirty-dot" title="未保存"> ●</span>}
<span className="titlebar-app"> — new-markdown</span>
</span>
{tauri && (
<span className="window-controls">
<button aria-label="最小化" onClick={() => void winAction('min')}>—</button>
<button aria-label="最大化或还原" onClick={() => void winAction('max')}>□</button>
<button aria-label="关闭" className="wc-close" onClick={() => void winAction('close')}>×</button>
</span>
)}
</header>
)
}

134
src/editor/commands.ts Normal file
View File

@@ -0,0 +1,134 @@
// 段落/格式命令:全部走 Milkdown 命令系统(一命令 = 一事务 = 一步撤销)。
import type { Editor } from '@milkdown/kit/core'
import { commandsCtx } from '@milkdown/kit/core'
import {
toggleStrongCommand, toggleEmphasisCommand, toggleInlineCodeCommand, toggleLinkCommand,
wrapInHeadingCommand, turnIntoTextCommand, wrapInBlockquoteCommand,
wrapInBulletListCommand, wrapInOrderedListCommand, createCodeBlockCommand, insertHrCommand,
insertImageCommand,
} from '@milkdown/kit/preset/commonmark'
import {
toggleStrikethroughCommand, insertTableCommand, addRowAfterCommand, addColAfterCommand,
setAlignCommand, goToNextTableCellCommand, goToPrevTableCellCommand,
} from '@milkdown/kit/preset/gfm'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { deleteRow, deleteColumn, deleteTable } from '@milkdown/kit/prose/tables'
function call(editor: Editor, key: unknown, payload?: unknown) {
editor.action((ctx) => {
// commandsCtx.call 的键是带载荷类型的 CmdKey<T>;此处统一擦除为 unknown 做分发,
// 各命令的载荷在下方调用点按对应命令的签名传入。
const manager = ctx.get(commandsCtx) as unknown as { call: (k: unknown, p?: unknown) => boolean }
manager.call(key, payload)
})
}
export const editorCommands = {
bold: (e: Editor) => call(e, toggleStrongCommand.key),
italic: (e: Editor) => call(e, toggleEmphasisCommand.key),
strikethrough: (e: Editor) => call(e, toggleStrikethroughCommand.key),
inlineCode: (e: Editor) => call(e, toggleInlineCodeCommand.key),
link: (e: Editor, href: string) => call(e, toggleLinkCommand.key, { href }),
paragraph: (e: Editor) => call(e, turnIntoTextCommand.key),
heading: (e: Editor, level: number) => call(e, wrapInHeadingCommand.key, level),
blockquote: (e: Editor) => call(e, wrapInBlockquoteCommand.key),
bulletList: (e: Editor) => call(e, wrapInBulletListCommand.key),
orderedList: (e: Editor) => call(e, wrapInOrderedListCommand.key),
codeBlock: (e: Editor) => call(e, createCodeBlockCommand.key),
hr: (e: Editor) => call(e, insertHrCommand.key),
image: (e: Editor, src: string, alt: string) => call(e, insertImageCommand.key, { src, alt }),
// ——— 表格(阶段 3)———
insertTable: (e: Editor, row = 3, col = 3) => call(e, insertTableCommand.key, { row, col }),
addRowAfter: (e: Editor) => call(e, addRowAfterCommand.key),
addColAfter: (e: Editor) => call(e, addColAfterCommand.key),
setAlign: (e: Editor, align: 'left' | 'center' | 'right') => setAlignColumn(e, align),
nextCell: (e: Editor) => call(e, goToNextTableCellCommand.key),
prevCell: (e: Editor) => call(e, goToPrevTableCellCommand.key),
deleteRow: (e: Editor) => proseTableCommand(e, deleteRow),
deleteCol: (e: Editor) => proseTableCommand(e, deleteColumn),
deleteTable: (e: Editor) => proseTableCommand(e, deleteTable),
}
/**
* 整列对齐:GFM 的对齐由表头行定义(序列化时从表头单元格 attrs 汇总),
* setAlignCommand 本身只改选区所在单元格;这里先把选区移到同列表头格执行,再恢复原选区,
* 这样用户在任意数据格点对齐都生效。
*/
export function setAlignColumn(editor: Editor, align: 'left' | 'center' | 'right') {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const $from = state.selection.$from
let cellPos = -1
let rowPos = -1
let tablePos = -1
for (let d = 1; d <= $from.depth; d++) {
const name = $from.node(d)?.type.name
if (name === 'table_cell' || name === 'table_header') cellPos = $from.before(d)
if (name === 'table_row' || name === 'table_header_row') rowPos = $from.before(d)
if (name === 'table') tablePos = $from.before(d)
}
if (cellPos < 0 || rowPos < 0 || tablePos < 0) return
const rowNode = state.doc.nodeAt(rowPos)
const tableNode = state.doc.nodeAt(tablePos)
if (!rowNode || !tableNode) return
// 当前列下标:行内排在当前格之前的单元格数量
let colIndex = 0
let acc = rowPos + 1
rowNode.forEach((child) => {
if (acc < cellPos) colIndex += 1
acc += child.nodeSize
})
// 表头行同列单元格的文档位置
const headerRow = tableNode.firstChild
if (!headerRow) return
let headerCellPos = -1
let hacc = tablePos + 1 + 1 // 表格内容起点 + 表头行边框
headerRow.forEach((child, _offset, index) => {
if (index === colIndex) headerCellPos = hacc
hacc += child.nodeSize
})
if (headerCellPos < 0) return
const original = state.selection
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, headerCellPos + 2)))
ctx.get(commandsCtx).call(setAlignCommand.key, align)
const now = view.state
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, Math.min(original.from, now.doc.content.size))))
})
}
/** 插入 Mermaid 代码块(language=mermaid,节点视图自动渲染预览)。 */
export function insertMermaidBlock(editor: Editor) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const schema = view.state.schema
const type = schema.nodes.code_block
if (!type) return
const node = type.create({ language: 'mermaid' }, schema.text('graph TD\n A[开始] --> B[结束]'))
view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView())
view.focus()
})
}
/** 插入数学公式块(plugin-math 的 math_block 节点,序列化为 $$ 块)。 */
export function insertMathBlock(editor: Editor) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const schema = view.state.schema
const type = schema.nodes.math_block
if (!type) return
// math_block 是 atom 节点,LaTeX 存在 attrs.value 中(plugin-math 的 schema 定义),不走文本内容
const node = type.create({ value: 'E = mc^2' })
view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView())
view.focus()
})
}
type ProseTableCmd = (state: unknown, dispatch: (tr: unknown) => void) => boolean
function proseTableCommand(editor: Editor, cmd: unknown) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
;(cmd as ProseTableCmd)(view.state, view.dispatch as (tr: unknown) => void)
})
}

49
src/editor/cursorSync.ts Normal file
View File

@@ -0,0 +1,49 @@
// 渲染模式 ⇄ 源码模式的光标同步:以「当前文本块的纯文本 + 块内偏移」为锚点互相定位。
// 这是近似同步(需求 §4.3 的口径即「尽可能一致」):锚点文本在对方文档中唯一出现时精确命中,
// 重复出现时取第一个匹配;找不到时回落到文档开头,绝不抛错、不丢内容。
export interface CursorAnchor {
blockText: string
offsetInBlock: number
}
const LEADING_MARKERS = /^\s*(?:#{1,6}\s+|>\s?|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/
/** 源码中某个偏移所在行的纯文本与行内偏移(剥掉行首 Markdown 标记)。 */
export function anchorFromMarkdown(markdown: string, offset: number): CursorAnchor {
const clamped = Math.max(0, Math.min(offset, markdown.length))
const lineStart = markdown.lastIndexOf('\n', Math.max(0, clamped - 1)) + 1
let lineEnd = markdown.indexOf('\n', clamped)
if (lineEnd < 0) lineEnd = markdown.length
const line = markdown.slice(lineStart, lineEnd)
const marker = LEADING_MARKERS.exec(line)?.[0] ?? ''
const plain = line.slice(marker.length)
const offsetInBlock = Math.max(0, Math.min(clamped - lineStart - marker.length, plain.length))
return { blockText: plain, offsetInBlock }
}
/** 在源码中定位锚点块,返回应放置的光标偏移;找不到返回 0。 */
export function offsetInMarkdown(markdown: string, anchor: CursorAnchor): number {
if (!anchor.blockText) return 0
const lines = markdown.split('\n')
let pos = 0
for (const line of lines) {
const marker = LEADING_MARKERS.exec(line)?.[0] ?? ''
const plain = line.slice(marker.length)
if (plain === anchor.blockText) {
return pos + marker.length + Math.min(anchor.offsetInBlock, plain.length)
}
pos += line.length + 1
}
// 退一步:子串查找(锚点块与源码行不完全一致时,如行内格式被序列化改写)
const idx = markdown.indexOf(anchor.blockText)
return idx >= 0 ? idx + Math.min(anchor.offsetInBlock, anchor.blockText.length) : 0
}
/** 在渲染文档的文本块序列中定位锚点块下标;找不到返回 -1。 */
export function findBlockIndex(blockTexts: string[], anchor: CursorAnchor): number {
if (!anchor.blockText) return -1
const exact = blockTexts.findIndex((t) => t === anchor.blockText)
if (exact >= 0) return exact
return blockTexts.findIndex((t) => t.includes(anchor.blockText) || anchor.blockText.includes(t))
}

137
src/editor/editor.ts Normal file
View File

@@ -0,0 +1,137 @@
// 编辑器装配层(阶段 0 工厂的产品化版本):UI 与测试共用此创建路径。
// 阶段 2/3 的插件(标记显隐装饰、数学、Mermaid、高亮)在此按序叠加,不改调用方。
import {
Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx, commandsCtx,
remarkStringifyOptionsCtx,
} from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { gfm } from '@milkdown/kit/preset/gfm'
import { history, undoCommand, redoCommand } from '@milkdown/kit/plugin/history'
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { indent } from '@milkdown/kit/plugin/indent'
import { math } from '@milkdown/plugin-math'
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, selectionInTable } from './plugins'
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
export interface CursorInfo { line: number; col: number }
export interface EditorCallbacks {
onMarkdown?: (markdown: string) => void
onCursor?: (cursor: CursorInfo) => void
/** 选区进出表格时通知(阶段 3 表格工具栏);第二参为当前表格 DOM(不在表格内时为 null) */
onTableContext?: (inTable: boolean, tableEl: HTMLElement | null) => void
}
export interface EditorOptions {
/** 粘贴/拖入图片文件的落盘处理:返回应写入文档的图片地址(相对路径或 data URL)。缺省用会话级对象 URL。 */
onImageFile?: (file: File) => Promise<string>
}
export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, callbacks: EditorCallbacks = {}, options: EditorOptions = {}) {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, initialMarkdown)
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
const lm = ctx.get(listenerCtx)
if (callbacks.onMarkdown) {
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
}
if (callbacks.onCursor || callbacks.onTableContext) {
lm.selectionUpdated((selCtx, selection) => {
callbacks.onCursor?.(cursorFromSelection(selection))
if (callbacks.onTableContext) {
const inTable = selectionInTable(selection as never)
let tableEl: HTMLElement | null = null
if (inTable) {
try {
const view = selCtx.get(editorViewCtx)
const at = view.domAtPos(selection.from)
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
} catch { /* 定位失败时工具栏不显示 */ }
}
callbacks.onTableContext(inTable, tableEl)
}
})
}
ctx.update(uploadConfig.key, (prev: UploadOptions) => ({
...prev,
uploader: (async (files: FileList, schema: { nodes: { image?: { createAndFill: (attrs: { src: string; alt: string }) => unknown } } }) => {
const nodes: unknown[] = []
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue
const src = options.onImageFile ? await options.onImageFile(file) : URL.createObjectURL(file)
const imageNode = schema.nodes.image?.createAndFill({ src, alt: file.name })
if (imageNode) nodes.push(imageNode)
}
return nodes
}) as UploadOptions['uploader'],
}))
})
.use(commonmark)
.use(gfm)
.use(history)
.use(listener)
.use(clipboard)
.use(indent)
.use(createSyntaxRevealPlugin())
.use(createCodeBlockTabPlugin())
.use(createTableTabPlugin())
.use(createListItemViewPlugin())
.use(createCodeBlockViewPlugin())
.use(math)
.use(upload)
return editor
}
interface PmSelection {
$from: {
parentOffset: number
depth: number
before: (depth?: number) => number
doc: {
descendants: (fn: (node: { isTextblock: boolean }, pos: number) => boolean | void) => void
}
}
}
/** 由 ProseMirror 选区推导行列:行 = 选区所在文本块在全文文本块序列中的序号;列 = 块内偏移 + 1。 */
export function cursorFromSelection(selection: unknown): CursorInfo {
const sel = selection as PmSelection
const $from = sel.$from
const parentPos = $from.before($from.depth)
let line = 0
let current = 1
$from.doc.descendants((node, pos) => {
if (node.isTextblock) {
line += 1
if (pos === parentPos) current = line
}
return true
})
return { line: current, col: $from.parentOffset + 1 }
}
export function getMarkdown(editor: Editor): string {
return editor.action((ctx) => {
const serializer = ctx.get(serializerCtx)
const view = ctx.get(editorViewCtx)
return serializer(view.state.doc)
})
}
export function callUndo(editor: Editor) {
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
}
export function callRedo(editor: Editor) {
editor.action((ctx) => ctx.get(commandsCtx).call(redoCommand.key))
}
export function focusEditor(editor: Editor) {
editor.action((ctx) => ctx.get(editorViewCtx).focus())
}

204
src/editor/nodeViews.ts Normal file
View File

@@ -0,0 +1,204 @@
// 任务列表节点视图(阶段 2 补齐):
// Kit 的 preset-gfm 只把任务项渲染为 <li data-checked>,没有可点击的复选框(那是 Crepe 的组件层能力)。
// 这里给 list_item 注册自有 NodeView:任务项在内容前渲染真实 checkbox,点击切换 checked 属性
//(走一条事务,可撤销;序列化自动变回 - [ ] / - [x])。普通列表项不渲染复选框,外观不变。
import { nodeViewCtx } from '@milkdown/kit/core'
import type { MilkdownPlugin } from '@milkdown/kit/ctx'
import type { NodeViewConstructor } from '@milkdown/kit/prose/view'
interface ListItemNode {
type: { name: string }
attrs: { checked?: boolean | null; label?: string; listType?: string; spread?: boolean }
}
const listItemView: NodeViewConstructor = (node, view, getPos) => {
const item = node as unknown as ListItemNode
const dom = document.createElement('li')
dom.dataset.itemType = item.attrs.checked != null ? 'task' : 'list'
if (item.attrs.label) dom.dataset.label = item.attrs.label
if (item.attrs.listType) dom.dataset.listType = item.attrs.listType
if (item.attrs.checked != null) dom.dataset.checked = String(item.attrs.checked)
let checkbox: HTMLInputElement | null = null
if (item.attrs.checked != null) {
dom.classList.add('task-item')
checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'task-checkbox'
checkbox.checked = Boolean(item.attrs.checked)
checkbox.contentEditable = 'false'
checkbox.addEventListener('mousedown', (e) => e.preventDefault())
checkbox.addEventListener('click', (e) => {
e.preventDefault()
const pos = getPos()
if (pos == null) return
const current = view.state.doc.nodeAt(pos)
if (!current) return
const next = !current.attrs.checked
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, checked: next }))
})
dom.appendChild(checkbox)
}
const contentDOM = document.createElement('div')
contentDOM.className = 'task-content'
contentDOM.style.display = 'contents'
dom.appendChild(contentDOM)
return {
dom,
contentDOM,
update: (updated) => {
const next = updated as unknown as ListItemNode
if (next.type.name !== 'list_item') return false
const wasTask = item.attrs.checked != null
const isTask = next.attrs.checked != null
if (wasTask !== isTask) return false // 任务性变化时交回 PM 重建视图
if (checkbox && next.attrs.checked != null) {
checkbox.checked = Boolean(next.attrs.checked)
dom.dataset.checked = String(next.attrs.checked)
}
return true
},
stopEvent: (event) => (checkbox ? event.target === checkbox : false),
ignoreMutation: (mutation) => mutation.target === dom && mutation.type === 'attributes',
}
}
export function createListItemViewPlugin(): MilkdownPlugin {
return (ctx) => async () => {
ctx.update(nodeViewCtx, (views) => [
...views.filter(([id]) => id !== 'list_item'),
['list_item', listItemView] as [string, NodeViewConstructor],
])
}
}
// ————————————————— Mermaid 代码块视图(阶段 3) —————————————————
// code_block 的 language 为 mermaid 时,节点视图渲染 SVG 预览并提供「预览/源码」切换;
// 其他语言维持标准 pre>code 结构。Mermaid 库按需懒加载,只在文档真有图表时才付出体积。
interface CodeBlockNode {
type: { name: string }
attrs: { language?: string }
textContent: string
}
let mermaidSeq = 0
const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
let current = node as unknown as CodeBlockNode
const isMermaid = () => current.attrs.language === 'mermaid'
if (!isMermaid()) {
const pre = document.createElement('pre')
const code = document.createElement('code')
if (current.attrs.language) code.className = `language-${current.attrs.language}`
code.spellcheck = false
pre.appendChild(code)
return {
dom: pre,
contentDOM: code,
update: (updated) => {
const next = updated as unknown as CodeBlockNode
if (next.type.name !== 'code_block') return false
if ((next.attrs.language === 'mermaid') !== isMermaid()) return false
current = next
return true
},
}
}
let mode: 'preview' | 'source' = 'preview'
let renderTimer: ReturnType<typeof setTimeout> | null = null
mermaidSeq += 1
const renderId = `new-markdown-mermaid-${mermaidSeq}`
const dom = document.createElement('div')
dom.className = 'mermaid-block'
const head = document.createElement('div')
head.className = 'mermaid-head'
const label = document.createElement('span')
label.textContent = 'Mermaid 图表'
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.textContent = '编辑源码'
head.appendChild(label)
head.appendChild(toggle)
const preview = document.createElement('div')
preview.className = 'mermaid-preview'
preview.contentEditable = 'false'
const pre = document.createElement('pre')
const code = document.createElement('code')
code.spellcheck = false
pre.appendChild(code)
pre.style.display = 'none'
dom.appendChild(head)
dom.appendChild(preview)
dom.appendChild(pre)
const applyMode = () => {
preview.style.display = mode === 'preview' ? '' : 'none'
pre.style.display = mode === 'source' ? '' : 'none'
toggle.textContent = mode === 'preview' ? '编辑源码' : '查看预览'
}
const render = async () => {
if (mode !== 'preview') return
const source = current.textContent
try {
const mermaid = (await import('mermaid')).default
mermaid.initialize({
startOnLoad: false,
theme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
})
const { svg } = await mermaid.render(renderId, source)
preview.innerHTML = svg
preview.classList.remove('mermaid-error')
} catch (e) {
preview.textContent = `图表渲染失败:${e instanceof Error ? e.message : String(e)}(可切换到源码修改)`
preview.classList.add('mermaid-error')
}
}
const scheduleRender = () => {
if (renderTimer) clearTimeout(renderTimer)
renderTimer = setTimeout(() => void render(), 250)
}
toggle.addEventListener('mousedown', (e) => e.preventDefault())
toggle.addEventListener('click', () => {
mode = mode === 'preview' ? 'source' : 'preview'
applyMode()
if (mode === 'preview') void render()
})
applyMode()
scheduleRender()
return {
dom,
contentDOM: code,
update: (updated) => {
const next = updated as unknown as CodeBlockNode
if (next.type.name !== 'code_block') return false
if ((next.attrs.language === 'mermaid') !== isMermaid()) return false
const changed = next.textContent !== current.textContent
current = next
if (changed) scheduleRender()
return true
},
stopEvent: (event) => event.target === toggle,
ignoreMutation: (mutation) => preview.contains(mutation.target as Node) || head.contains(mutation.target as Node),
destroy: () => { if (renderTimer) clearTimeout(renderTimer) },
}
}
export function createCodeBlockViewPlugin(): MilkdownPlugin {
return (ctx) => async () => {
ctx.update(nodeViewCtx, (views) => [
...views.filter(([id]) => id !== 'code_block'),
['code_block', codeBlockView] as [string, NodeViewConstructor],
])
}
}

36
src/editor/paste.ts Normal file
View File

@@ -0,0 +1,36 @@
// 纯文本插入:菜单「粘贴」走 Clipboard API 读文本后调用此函数,
// 替代阶段 1 的 execCommand('paste')(WebView2 策略下不可靠)。
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
export function insertPlainText(editor: Editor, text: string): boolean {
if (!text) return false
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to } = view.state.selection
view.dispatch(view.state.tr.insertText(text, from, to).scrollIntoView())
view.focus()
})
return true
}
/** 在选区处插入带链接的文本(附件以 Markdown 链接形式落盘,需求 §6.4)。 */
export function insertLinkText(editor: Editor, text: string, href: string): boolean {
if (!text || !href) return false
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const schema = view.state.schema
const mark = schema.marks.link?.create({ href })
if (!mark) return
// 直接 replaceSelectionWith 插入带标记的文本节点会被 ProseMirror 按插入点活动标记归一化而丢标记;
// 正确做法是先把 link 设为 storedMarks 再 insertText,插入后清掉,避免污染后续输入。
const { from, to } = view.state.selection
const tr = view.state.tr
tr.setStoredMarks([mark])
tr.insertText(text, from, to)
tr.setStoredMarks([])
view.dispatch(tr.scrollIntoView())
view.focus()
})
return true
}

251
src/editor/plugins.ts Normal file
View File

@@ -0,0 +1,251 @@
// 编辑器自研 ProseMirror 插件组(阶段 2):
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
// 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。
import { Plugin, PluginKey } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { $prose } from '@milkdown/kit/utils'
import { commandsCtx } from '@milkdown/kit/core'
import { addRowAfterCommand, goToNextTableCellCommand, goToPrevTableCellCommand } from '@milkdown/kit/preset/gfm'
import { isImeComposing } from '../utils/ime'
// ————————————————— syntaxReveal —————————————————
interface MarkSpec { open: string; close: (attrs: Record<string, unknown>) => string }
const MARK_SPECS: Record<string, MarkSpec> = {
strong: { open: '**', close: () => '**' },
emphasis: { open: '*', close: () => '*' },
inlineCode: { open: '`', close: () => '`' },
strike_through: { open: '~~', close: () => '~~' },
link: { open: '[', close: (attrs) => `](${typeof attrs.href === 'string' ? attrs.href : ''})` },
}
function markerDom(text: string): HTMLElement {
const span = document.createElement('span')
span.className = 'md-marker'
span.textContent = text
return span
}
interface PmNodeLike {
type: { name: string }
attrs: Record<string, unknown>
isTextblock: boolean
isText?: boolean
text?: string
marks?: { type: { name: string }; attrs: Record<string, unknown> }[]
content: { size: number }
}
interface PmSelectionLike {
from: number
to: number
$from: {
depth: number
parent: PmNodeLike
parentOffset: number
start: (depth?: number) => number
before: (depth?: number) => number
node: (depth: number) => PmNodeLike
}
}
/** 由文档与选区计算标记装饰。导出供测试直接断言。 */
export function buildRevealDecorations(doc: unknown, selection: unknown): Decoration[] {
const sel = selection as PmSelectionLike
const $from = sel.$from
const parent = $from.parent
if (!parent?.isTextblock) return []
const decos: Decoration[] = []
const blockStart = $from.start($from.depth)
// 标题:光标在标题内时在内容前显示 # 前缀
if (parent.type.name === 'heading') {
const level = typeof parent.attrs.level === 'number' ? parent.attrs.level : 1
decos.push(Decoration.widget(blockStart, () => markerDom(`${'#'.repeat(level)} `), { side: -1 }))
}
// 引用:光标所在段落若处于引用内,显示 > 前缀
for (let d = 1; d <= $from.depth; d++) {
if ($from.node(d)?.type.name === 'blockquote') {
decos.push(Decoration.widget(blockStart, () => markerDom('> '), { side: -1 }))
break
}
}
// 行内标记:只揭示与选区相交的文本段的标记(折叠光标落在段内即揭示,含两端边界)
const children = (parent as unknown as { content: { forEach: (fn: (child: PmNodeLike, off: number) => void) => void } }).content
children.forEach((child, off) => {
// 文本子节点的长度是 text.length(其 content 为空 Fragment,size 为 0);非文本内联节点用 nodeSize 口径的 content+边距近似
const segSize = child.isText ? (child.text?.length ?? 0) : (child.content?.size ?? 0) + 2
const segStart = blockStart + off
const segEnd = segStart + segSize
if (!child.isText || !child.marks?.length) return
const hit = sel.from < segEnd && sel.to >= segStart || (sel.from >= segStart && sel.from <= segEnd)
if (!hit) return
for (const mark of child.marks) {
const spec = MARK_SPECS[mark.type.name]
if (!spec) continue
decos.push(Decoration.widget(segStart, () => markerDom(spec.open), { side: -1 }))
decos.push(Decoration.widget(segEnd, () => markerDom(spec.close(mark.attrs)), { side: 1 }))
}
})
void doc
return decos
}
const revealKey = new PluginKey<{ decos: DecorationSet }>('syntax-reveal')
export function createSyntaxRevealPlugin() {
return $prose(() => {
// 每个编辑器实例独立的组合状态:IME 组合期间冻结装饰重算,避免干扰组合输入的 DOM。
const composing = { on: false }
return new Plugin<{ decos: DecorationSet }>({
key: revealKey,
state: {
init: (_config, state) => ({
decos: DecorationSet.create(state.doc, buildRevealDecorations(state.doc, state.selection)),
}),
apply: (tr, prev, _oldState, newState) => {
if (composing.on) return prev
if (!tr.docChanged && !tr.selectionSet && !tr.getMeta(revealKey)) return prev
return { decos: DecorationSet.create(newState.doc, buildRevealDecorations(newState.doc, newState.selection)) }
},
},
props: {
decorations: (state) => revealKey.getState(state)?.decos ?? DecorationSet.empty,
},
view: (view) => {
const onStart = () => { composing.on = true }
const onEnd = () => {
composing.on = false
// 组合结束后补一次重算,让标记显隐跟上最终选区
view.dispatch(view.state.tr.setMeta(revealKey, 'refresh'))
}
view.dom.addEventListener('compositionstart', onStart)
view.dom.addEventListener('compositionend', onEnd)
return {
destroy: () => {
view.dom.removeEventListener('compositionstart', onStart)
view.dom.removeEventListener('compositionend', onEnd)
},
}
},
})
})
}
// ————————————————— codeBlockTab —————————————————
interface MinimalView {
state: {
selection: { $from: { parent: PmNodeLike & { textContent: string }; parentOffset: number; start: (d?: number) => number; depth: number } }
tr: {
insertText: (text: string, from?: number, to?: number) => unknown
delete: (from: number, to: number) => unknown
}
}
dispatch: (tr: unknown) => void
}
/** 代码块内缩进/反缩进。返回 true 表示已处理。导出供测试。 */
export function applyCodeBlockIndent(view: MinimalView, outdent: boolean): boolean {
const { $from } = view.state.selection
if ($from.parent.type.name !== 'code_block') return false
const text = $from.parent.textContent
const offset = $from.parentOffset
const blockStart = $from.start($from.depth)
if (!outdent) {
view.dispatch(view.state.tr.insertText(' ', blockStart + offset))
return true
}
const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1
const leading = text.slice(lineStart, lineStart + 2)
const remove = leading.startsWith(' ') ? 2 : leading.startsWith(' ') ? 1 : 0
if (remove === 0) return true // 已在行首无缩进:吞掉 Tab,避免焦点跳走
view.dispatch(view.state.tr.delete(blockStart + lineStart, blockStart + lineStart + remove))
return true
}
// ————————————————— 表格 Tab 导航(阶段 3) —————————————————
interface AncestorLike {
depth: number
node: (d: number) => { type: { name: string } }
before: (d?: number) => number
}
/** 选区是否在表格单元格内。 */
export function selectionInTable(selection: { $from: AncestorLike }): boolean {
const $from = selection.$from
for (let d = 1; d <= $from.depth; d++) {
const name = $from.node(d)?.type.name
if (name === 'table_cell' || name === 'table_header') return true
}
return false
}
/** 当前单元格是否为整表最后一个单元格。 */
export function isLastTableCell(state: { selection: { $from: AncestorLike }; doc: PmDocLike }): boolean {
const $from = state.selection.$from
let tablePos = -1
let cellPos = -1
for (let d = 1; d <= $from.depth; d++) {
const name = $from.node(d)?.type.name
if (name === 'table_cell' || name === 'table_header') cellPos = $from.before(d)
if (name === 'table') tablePos = $from.before(d)
}
if (tablePos < 0 || cellPos < 0) return false
const table = state.doc.nodeAt(tablePos)
if (!table) return false
let total = 0
let currentIndex = -1
table.descendants((node: { type: { name: string } }, pos: number) => {
if (node.type.name === 'table_cell' || node.type.name === 'table_header') {
if (tablePos + pos + 1 === cellPos) currentIndex = total
total += 1
}
return true
})
return currentIndex === total - 1
}
interface PmDocLike {
nodeAt: (pos: number) => { descendants: (fn: (node: { type: { name: string } }, pos: number) => boolean | void) => void } | null
}
export function createTableTabPlugin() {
return $prose((ctx) => new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key !== 'Tab' || isImeComposing(event)) return false
const selection = view.state.selection as unknown as { $from: AncestorLike }
if (!selectionInTable(selection)) return false
event.preventDefault()
const commands = ctx.get(commandsCtx) as unknown as { call: (k: unknown, p?: unknown) => boolean }
if (event.shiftKey) {
commands.call(goToPrevTableCellCommand.key)
return true
}
if (isLastTableCell(view.state as unknown as { selection: { $from: AncestorLike }; doc: PmDocLike })) {
commands.call(addRowAfterCommand.key)
}
commands.call(goToNextTableCellCommand.key)
return true
},
},
}))
}
export function createCodeBlockTabPlugin() {
return $prose(() => new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key !== 'Tab') return false
if (isImeComposing(event)) return false
const handled = applyCodeBlockIndent(view as unknown as MinimalView, event.shiftKey)
if (handled) event.preventDefault()
return handled
},
},
}))
}

7
src/main.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import App from './App'
import 'katex/dist/katex.min.css'
import './styles.css'
// 不用 StrictMode:开发期双重挂载会重复创建 Milkdown 编辑器(阶段 0 已确认)。
createRoot(document.getElementById('root')!).render(<App />)

141
src/services/platform.ts Normal file
View File

@@ -0,0 +1,141 @@
// 平台服务层:所有磁盘/对话框访问收口于此。
// - Tauri 模式:走 Rust 命令(编码探测 + 原子写)与官方 dialog 插件;
// - Web 降级模式(npm run dev 在浏览器里调 UI 时):文件打开用 <input type=file> 读入,
// 保存退化为下载。降级仅用于界面调试,UI 状态栏会明确标注当前模式,不冒充桌面能力。
export interface ReadResult {
content: string
encoding: string
lineEnding: string
hadBom: boolean
}
export interface DirEntry {
name: string
path: string
isDir: boolean
}
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
}
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const { invoke } = await import('@tauri-apps/api/core')
return invoke<T>(cmd, args)
}
export async function pickOpenFile(): Promise<string | null> {
if (!isTauri()) return null
const { open } = await import('@tauri-apps/plugin-dialog')
const picked = await open({
multiple: false,
directory: false,
filters: [
{ name: 'Markdown', extensions: ['md', 'markdown'] },
{ name: '文本文件', extensions: ['txt'] },
{ name: '全部文件', extensions: ['*'] },
],
})
return typeof picked === 'string' ? picked : null
}
export async function pickSaveFile(defaultName: string): Promise<string | null> {
if (!isTauri()) return null
const { save } = await import('@tauri-apps/plugin-dialog')
const picked = await save({
defaultPath: defaultName,
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
return picked ?? null
}
export async function pickFolder(): Promise<string | null> {
if (!isTauri()) return null
const { open } = await import('@tauri-apps/plugin-dialog')
const picked = await open({ multiple: false, directory: true })
return typeof picked === 'string' ? picked : null
}
export async function readTextFile(path: string): Promise<ReadResult> {
return invoke<ReadResult>('read_text_file', { path })
}
export async function writeTextFile(path: string, content: string, keepBom: boolean): Promise<void> {
await invoke<void>('write_text_file', { path, content, keepBom })
}
export async function listDir(path: string): Promise<DirEntry[]> {
return invoke<DirEntry[]>('list_dir', { path })
}
async function pickFileWithFilters(filters: { name: string; extensions: string[] }[]): Promise<string | null> {
if (!isTauri()) return null
const { open } = await import('@tauri-apps/plugin-dialog')
const picked = await open({ multiple: false, directory: false, filters })
return typeof picked === 'string' ? picked : null
}
export function pickImageFile(): Promise<string | null> {
return pickFileWithFilters([{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'] }])
}
export function pickAnyFile(): Promise<string | null> {
return pickFileWithFilters([{ name: '全部文件', extensions: ['*'] }])
}
/** 把外部文件复制进文档资源目录,返回相对路径(阶段 3 图片/附件落盘)。 */
export async function copyToAssets(source: string, docPath: string): Promise<string> {
return invoke<string>('copy_to_assets', { source, docPath })
}
/** 把字节(粘贴/拖入的图片)写入文档资源目录,返回相对路径。 */
export async function saveBytesToAssets(docPath: string, fileName: string, bytes: number[]): Promise<string> {
return invoke<string>('save_bytes_to_assets', { docPath, fileName, bytes })
}
/** Web 降级:选图片并读为 data URL。 */
export function pickAndReadWebImage(): Promise<{ name: string; dataUrl: string } | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return resolve(null)
const reader = new FileReader()
reader.onload = () => resolve({ name: file.name, dataUrl: String(reader.result) })
reader.onerror = () => resolve(null)
reader.readAsDataURL(file)
}
input.oncancel = () => resolve(null)
input.click()
})
}
/** Web 降级:选文件并读入文本。返回 null 表示用户取消。 */
export function pickAndReadWebFile(): Promise<{ name: string; content: string } | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.md,.markdown,.txt,text/plain,text/markdown'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return resolve(null)
resolve({ name: file.name, content: await file.text() })
}
input.oncancel = () => resolve(null)
input.click()
})
}
/** Web 降级:把内容作为下载交付(等价于另存为)。 */
export function downloadTextFile(name: string, content: string): void {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
URL.revokeObjectURL(url)
}

114
src/stores/tabs.ts Normal file
View File

@@ -0,0 +1,114 @@
// 标签页文档会话模型(纯状态 + reducer,单测覆盖)。
// 每个标签 = 一份 EditorSession:正文 content 与上次保存 savedContent 分离,dirty 由二者比较得出,
// 避免「改回原样仍显示未保存」这类误报。
export interface DocTab {
id: string
/** 磁盘路径;未命名文档为 null */
path: string | null
title: string
content: string
savedContent: string
encoding: string
lineEnding: string
hadBom: boolean
}
export interface TabsState {
tabs: DocTab[]
activeId: string | null
}
let seq = 0
export function nextTabId(): string {
seq += 1
return `tab-${Date.now().toString(36)}-${seq}`
}
export function createTab(partial: Partial<DocTab> & { content: string }): DocTab {
return {
id: partial.id ?? nextTabId(),
path: partial.path ?? null,
title: partial.title ?? '未命名',
content: partial.content,
savedContent: partial.savedContent ?? partial.content,
encoding: partial.encoding ?? 'UTF-8',
lineEnding: partial.lineEnding ?? 'LF',
hadBom: partial.hadBom ?? false,
}
}
export function isDirty(tab: DocTab): boolean {
return tab.content !== tab.savedContent
}
export type TabsAction =
| { type: 'open'; tab: DocTab }
| { type: 'activate'; id: string }
| { type: 'updateContent'; id: string; content: string }
| { type: 'saved'; id: string; path?: string; title?: string }
| { type: 'close'; id: string }
| { type: 'closeOthers'; id: string }
| { type: 'closeRight'; id: string }
| { type: 'move'; id: string; toIndex: number }
export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
switch (action.type) {
case 'open': {
// 同一文件已打开则直接激活,不重复建标签
if (action.tab.path) {
const existing = state.tabs.find((t) => t.path === action.tab.path)
if (existing) return { ...state, activeId: existing.id }
}
return { tabs: [...state.tabs, action.tab], activeId: action.tab.id }
}
case 'activate':
return state.tabs.some((t) => t.id === action.id) ? { ...state, activeId: action.id } : state
case 'updateContent':
return {
...state,
tabs: state.tabs.map((t) => (t.id === action.id ? { ...t, content: action.content } : t)),
}
case 'saved':
return {
...state,
tabs: state.tabs.map((t) =>
t.id === action.id
? { ...t, savedContent: t.content, path: action.path ?? t.path, title: action.title ?? t.title }
: t,
),
}
case 'close': {
const idx = state.tabs.findIndex((t) => t.id === action.id)
if (idx < 0) return state
const tabs = state.tabs.filter((t) => t.id !== action.id)
let activeId = state.activeId
if (state.activeId === action.id) {
const next = tabs[Math.min(idx, tabs.length - 1)]
activeId = next ? next.id : null
}
return { tabs, activeId }
}
case 'closeOthers':
return { tabs: state.tabs.filter((t) => t.id === action.id), activeId: action.id }
case 'closeRight': {
const idx = state.tabs.findIndex((t) => t.id === action.id)
if (idx < 0) return state
return { ...state, tabs: state.tabs.slice(0, idx + 1) }
}
case 'move': {
const idx = state.tabs.findIndex((t) => t.id === action.id)
if (idx < 0 || action.toIndex < 0 || action.toIndex >= state.tabs.length) return state
const tabs = [...state.tabs]
const [moved] = tabs.splice(idx, 1)
tabs.splice(action.toIndex, 0, moved)
return { ...state, tabs }
}
default:
return state
}
}
export function activeTab(state: TabsState): DocTab | null {
return state.tabs.find((t) => t.id === state.activeId) ?? null
}

149
src/styles.css Normal file
View File

@@ -0,0 +1,149 @@
/* new-markdown 阶段 1 样式:应用 UI 主题(CSS 变量)+ 文档排版基础。
视觉定调:中性、克制、石板色点缀、适度圆角、无渐变无投影堆叠(docs/04)。 */
:root {
--bg: #f6f6f4;
--bg-panel: #f0f0ee;
--bg-editor: #ffffff;
--border: #e0e0dc;
--text: #24292f;
--text-dim: #6b7280;
--accent: #43536a;
--accent-weak: #e8ecf1;
--hover: #e9e9e6;
--code-bg: #f4f4f2;
}
html[data-theme='dark'] {
--bg: #2b2b2b;
--bg-panel: #252525;
--bg-editor: #1f1f1f;
--border: #3a3a3a;
--text: #d6d6d3;
--text-dim: #9a9a96;
--accent: #7d93b2;
--accent-weak: #333c48;
--hover: #333333;
--code-bg: #2c2c2c;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif; color: var(--text); background: var(--bg); font-size: 14px; }
button { font: inherit; color: inherit; }
.app { display: flex; flex-direction: column; height: 100%; }
/* 标题栏 */
.titlebar { display: flex; align-items: center; gap: 9px; height: 38px; padding: 0 0 0 12px; background: var(--bg-panel); border-bottom: 1px solid var(--border); user-select: none; }
.app-mark { background: var(--accent); color: #fff; font-weight: 700; border-radius: 6px; padding: 1px 7px; }
.titlebar-name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.titlebar-app { color: var(--text-dim); }
.dirty-dot { color: #d08a2d; font-size: 10px; }
.window-controls { margin-left: auto; display: flex; height: 100%; }
.window-controls button { width: 46px; border: 0; background: transparent; cursor: pointer; font-size: 14px; }
.window-controls button:hover { background: var(--hover); }
.window-controls .wc-close:hover { background: #c42b1c; color: #fff; }
/* 菜单栏 */
.menubar { display: flex; padding: 2px 6px; background: var(--bg); border-bottom: 1px solid var(--border); user-select: none; position: relative; z-index: 40; }
.menu { position: relative; }
.menu-title { border: 0; background: transparent; padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.menu-title:hover, .menu-title.open { background: var(--hover); }
.menu-dropdown { position: absolute; top: 100%; left: 0; min-width: 250px; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 5px; box-shadow: 0 8px 24px rgba(0,0,0,0.13); }
.menu-item { display: flex; align-items: center; width: 100%; border: 0; background: transparent; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; text-align: left; }
.menu-item:hover:not(:disabled) { background: var(--accent-weak); }
.menu-item:disabled { color: var(--text-dim); cursor: default; }
.menu-check { width: 18px; flex: none; }
.menu-label { flex: 1; white-space: nowrap; }
.menu-shortcut { color: var(--text-dim); font-size: 12px; margin-left: 24px; }
.menu-divider { height: 1px; background: var(--border); margin: 5px 8px; }
/* 标签栏 */
.tabbar { display: flex; align-items: stretch; background: var(--bg-panel); border-bottom: 1px solid var(--border); min-height: 36px; overflow-x: auto; }
.tab { display: flex; align-items: center; gap: 7px; padding: 0 8px 0 14px; font-size: 13px; cursor: pointer; border-right: 1px solid var(--border); white-space: nowrap; color: var(--text-dim); }
.tab.active { background: var(--bg-editor); color: var(--text); box-shadow: inset 0 2px 0 var(--accent); }
.tab-title { max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
.tab-dirty { color: #d08a2d; font-size: 9px; }
.tab-close { border: 0; background: transparent; border-radius: 5px; cursor: pointer; padding: 1px 5px; color: var(--text-dim); }
.tab-close:hover { background: var(--hover); color: var(--text); }
.tab-new { border: 0; background: transparent; padding: 0 12px; cursor: pointer; color: var(--text-dim); font-size: 15px; }
.tab-new:hover { background: var(--hover); }
.context-menu { position: fixed; z-index: 90; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 5px; box-shadow: 0 8px 24px rgba(0,0,0,0.13); display: flex; flex-direction: column; min-width: 170px; }
.context-menu button { border: 0; background: transparent; text-align: left; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.context-menu button:hover { background: var(--accent-weak); }
/* 主区域 */
.main { flex: 1; display: flex; min-height: 0; }
.sidebar { flex: 1; display: flex; flex-direction: column; background: var(--bg-panel); border-right: 1px solid var(--border); min-width: 0; }
.sidebar-tabs { display: flex; gap: 4px; padding: 8px 10px 4px; }
.sidebar-tabs button { flex: 1; border: 0; background: transparent; padding: 5px 0; border-radius: 999px; cursor: pointer; font-size: 13px; color: var(--text-dim); }
.sidebar-tabs button.active { background: var(--accent-weak); color: var(--text); }
.outline, .filetree { overflow-y: auto; padding: 6px 8px 16px; }
.outline-item { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.outline-item:hover { background: var(--hover); }
.sidebar-empty { padding: 14px 12px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
.sidebar-action, .primary { background: var(--accent); color: #fff; border: 0; border-radius: 7px; padding: 6px 14px; cursor: pointer; }
.filetree-head { display: flex; align-items: center; gap: 6px; padding: 2px 4px 6px; }
.filetree-root { font-weight: 600; font-size: 13px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.filetree-head button { border: 0; background: transparent; cursor: pointer; border-radius: 5px; color: var(--text-dim); }
.filetree-head button:hover { background: var(--hover); }
.tree-item { display: flex; gap: 5px; width: 100%; text-align: left; border: 0; background: transparent; padding: 3.5px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tree-item:hover { background: var(--hover); }
.tree-item.active { background: var(--accent-weak); }
.tree-icon { color: var(--text-dim); width: 12px; flex: none; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; }
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; }
.source-cm .cm-editor { height: 100%; outline: none; }
.md-marker { color: var(--text-dim); opacity: 0.55; font-size: 0.9em; pointer-events: none; user-select: none; white-space: pre; }
.empty-state { max-width: 520px; margin: 18vh auto 0; text-align: center; color: var(--text-dim); }
.empty-state h2 { color: var(--text); font-weight: 600; }
.empty-state button { margin: 6px; border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; }
.empty-state button.primary { background: var(--accent); color: #fff; border-color: transparent; }
/* 状态栏 */
.statusbar { display: flex; gap: 16px; align-items: center; height: 26px; padding: 0 12px; background: var(--bg-panel); border-top: 1px solid var(--border); font-size: 12px; color: var(--text-dim); user-select: none; }
.status-right { margin-left: auto; display: flex; gap: 16px; }
/* 弹窗 */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20, 22, 26, 0.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: var(--bg-editor); color: var(--text); border-radius: 10px; padding: 20px 22px; width: min(480px, 92vw); max-height: 86vh; overflow-y: auto; box-shadow: 0 16px 48px rgba(0,0,0,0.25); }
.modal h3 { margin: 0 0 10px; font-size: 16px; }
.modal p { line-height: 1.7; font-size: 13.5px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.modal-actions button { border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; }
.modal-actions button.primary { background: var(--accent); color: #fff; border-color: transparent; }
.modal-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); color: var(--text); }
.modal-note { color: var(--text-dim); font-size: 12.5px; }
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
.shortcuts-table td:last-child { text-align: right; }
kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-family: Consolas, monospace; font-size: 12px; }
/* ——— 文档排版(基础主题,阶段 5 做完整主题系统) ——— */
.milkdown .ProseMirror { outline: none; font-size: calc(16.5px * var(--doc-zoom, 1)); line-height: 1.78; color: var(--text); caret-color: var(--accent); }
.milkdown .ProseMirror h1 { font-size: 1.85em; border-bottom: 1px solid var(--border); padding-bottom: 8px; line-height: 1.3; }
.milkdown .ProseMirror h2 { font-size: 1.5em; line-height: 1.35; }
.milkdown .ProseMirror h3 { font-size: 1.25em; }
.milkdown .ProseMirror blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid var(--accent); color: var(--text-dim); }
.milkdown .ProseMirror code { background: var(--code-bg); border-radius: 6px; padding: 2px 5px; font-family: "Cascadia Code", Consolas, monospace; font-size: 0.88em; }
.milkdown .ProseMirror pre { background: var(--code-bg); border-radius: 8px; padding: 14px 16px; overflow-x: auto; }
.milkdown .ProseMirror pre code { background: transparent; padding: 0; }
.milkdown .ProseMirror table { border-collapse: collapse; }
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
.milkdown .ProseMirror img { max-width: 100%; }
.milkdown .ProseMirror a { color: var(--accent); }
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }
.milkdown .ProseMirror .task-checkbox { margin-top: 0.42em; width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; flex: none; }
.milkdown .ProseMirror li[data-checked='true'] .task-content { color: var(--text-dim); }
.editor-pane-wrap { position: relative; }
.table-toolbar { position: absolute; z-index: 30; display: flex; gap: 2px; align-items: center; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 3px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); }
.table-toolbar button { border: 0; background: transparent; padding: 4px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; white-space: nowrap; }
.table-toolbar button:hover { background: var(--accent-weak); }
.table-toolbar .tt-sep { width: 1px; height: 16px; background: var(--border); margin: 0 3px; }
.mermaid-block { border: 1px solid var(--border); border-radius: 8px; margin: 10px 0; overflow: hidden; }
.mermaid-head { display: flex; align-items: center; justify-content: space-between; padding: 4px 10px; background: var(--code-bg); font-size: 12px; color: var(--text-dim); }
.mermaid-head button { border: 0; background: transparent; cursor: pointer; color: var(--accent); font-size: 12px; padding: 2px 6px; }
.mermaid-preview { padding: 14px; text-align: center; overflow-x: auto; }
.mermaid-preview svg { max-width: 100%; height: auto; }
.mermaid-preview.mermaid-error { color: #b3261e; font-size: 13px; text-align: left; white-space: pre-wrap; }
.mermaid-block pre { margin: 0 !important; border-radius: 0 !important; }

5
src/utils/ime.ts Normal file
View File

@@ -0,0 +1,5 @@
// IME 组合输入判定:组合期间(拼音未上屏)任何快捷键/命令都不得触发。
// keyCode 229 是浏览器对 IME 处理中按键的传统标记,isComposing 为标准属性,双保险。
export function isImeComposing(e: { isComposing?: boolean; keyCode?: number }): boolean {
return e.isComposing === true || e.keyCode === 229
}

44
src/utils/text.ts Normal file
View File

@@ -0,0 +1,44 @@
// 文本统计与大纲提取:纯函数,单测覆盖(tests/text.test.ts)。
/** 字数:中文按汉字计,英文/数字按词计,标点不计(需求 §10)。代码块是否计入由设置控制,默认计入。 */
export function countWords(markdown: string, includeCodeBlocks = true): number {
let text = markdown
if (!includeCodeBlocks) text = text.replace(/```[\s\S]*?(```|$)/g, ' ')
const cjk = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) ?? []).length
const words = (text.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').match(/[A-Za-z0-9]+(?:'[A-Za-z]+)?/g) ?? []).length
return cjk + words
}
export function countChars(markdown: string): number {
return [...markdown].length
}
export interface OutlineItem {
level: number
text: string
/** 在序列化 Markdown 中的行号(0 基),用于大纲点击定位的近似锚点 */
line: number
}
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 */
export function extractOutline(markdown: string): OutlineItem[] {
const items: OutlineItem[] = []
const lines = markdown.split('\n')
let inFence = false
lines.forEach((raw, i) => {
if (/^\s*(```|~~~)/.test(raw)) inFence = !inFence
if (inFence) return
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i })
})
return items
}
export function baseName(path: string): string {
const normalized = path.replace(/\\/g, '/')
return normalized.split('/').filter(Boolean).pop() ?? path
}
export function isMarkdownPath(path: string): boolean {
return /\.(md|markdown)$/i.test(path)
}

35
tests/cursorSync.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { anchorFromMarkdown, offsetInMarkdown, findBlockIndex } from '../src/editor/cursorSync'
const MD = '# 标题一行\n\n正文段落内容。\n\n- 列表项甲\n- [ ] 任务项乙\n\n> 引用一句\n'
describe('光标锚点同步', () => {
it('源码偏移 → 锚点:剥掉标题/列表/引用标记', () => {
const headingOffset = MD.indexOf('标题一行') + 2
expect(anchorFromMarkdown(MD, headingOffset)).toEqual({ blockText: '标题一行', offsetInBlock: 2 })
const taskOffset = MD.indexOf('任务项乙') + 1
expect(anchorFromMarkdown(MD, taskOffset)).toEqual({ blockText: '任务项乙', offsetInBlock: 1 })
const quoteOffset = MD.indexOf('引用一句')
expect(anchorFromMarkdown(MD, quoteOffset)).toEqual({ blockText: '引用一句', offsetInBlock: 0 })
})
it('锚点 → 源码偏移:往返一致', () => {
for (const text of ['标题一行', '正文段落内容。', '列表项甲', '任务项乙', '引用一句']) {
const start = MD.indexOf(text)
const anchor = anchorFromMarkdown(MD, start + 1)
expect(offsetInMarkdown(MD, anchor)).toBe(start + 1)
}
})
it('找不到锚点时回落 0,不抛错', () => {
expect(offsetInMarkdown(MD, { blockText: '不存在的块', offsetInBlock: 3 })).toBe(0)
expect(anchorFromMarkdown('', 10)).toEqual({ blockText: '', offsetInBlock: 0 })
})
it('findBlockIndex 精确优先、子串兜底', () => {
const blocks = ['标题一行', '正文段落内容。', '列表项甲']
expect(findBlockIndex(blocks, { blockText: '正文段落内容。', offsetInBlock: 0 })).toBe(1)
expect(findBlockIndex(blocks, { blockText: '正文段落', offsetInBlock: 0 })).toBe(1)
expect(findBlockIndex(blocks, { blockText: '没有', offsetInBlock: 0 })).toBe(-1)
})
})

View File

@@ -0,0 +1,60 @@
// 阶段门禁(架构 §7):阶段 0 的往返稳定性套件在正式工程上必须继续全绿。
import { describe, it, expect, afterEach } from 'vitest'
import type { Editor } from '@milkdown/kit/core'
import { createMarkdownEditor, getMarkdown, callUndo } from '../src/editor/editor'
const SAMPLE = `# 标题
包含**粗体**与任务:
- [ ] 待办
- [x] 完成
| 列一 | 列二 |
| :--- | ---: |
| 甲 | 1 |
\`\`\`ts
const n = 1;
\`\`\`
`
let editors: Editor[] = []
afterEach(async () => {
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
editors = []
document.body.innerHTML = ''
})
async function load(md: string): Promise<Editor> {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, md)
editors.push(editor)
await editor.create()
return editor
}
describe('编辑器回归(阶段 0 套件)', () => {
it('往返保真且第二轮逐字稳定', async () => {
const once = getMarkdown(await load(SAMPLE))
expect(once).toContain('**粗体**')
expect(once).toMatch(/-\s\[ \]\s待办/)
expect(once).toMatch(/-\s\[[xX]\]\s完成/)
expect(once).toContain('甲')
const twice = getMarkdown(await load(once))
expect(twice).toBe(once)
})
it('撤销命令仍可用', async () => {
const editor = await load('原文\n')
const { editorViewCtx } = await import('@milkdown/kit/core')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.insertText('X', view.state.doc.content.size))
})
expect(getMarkdown(editor)).toContain('X')
callUndo(editor)
expect(getMarkdown(editor)).not.toContain('X')
})
})

16
tests/setup.ts Normal file
View File

@@ -0,0 +1,16 @@
// jsdom 缺失的布局 API 垫片:ProseMirror View 在量测坐标时会调用它们。
// 只补「不抛错」的最小实现,不参与任何断言逻辑。
type RectLike = { x: number; y: number; width: number; height: number; top: number; left: number; right: number; bottom: number; toJSON: () => object }
const emptyRect: RectLike = { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, toJSON: () => ({}) }
if (typeof Range !== 'undefined') {
Range.prototype.getClientRects = Range.prototype.getClientRects ?? (() => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList)
Range.prototype.getBoundingClientRect = Range.prototype.getBoundingClientRect ?? (() => emptyRect as DOMRect)
}
if (typeof Element !== 'undefined') {
Element.prototype.getClientRects = Element.prototype.getClientRects ?? (() => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList)
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView ?? (() => undefined)
}
if (typeof document !== 'undefined' && !document.elementFromPoint) {
document.elementFromPoint = () => null
}

View File

@@ -0,0 +1,40 @@
// 源码模式(CodeMirror 6)冒烟:能挂载、锚点 API 可用、改动经 onChange 回传。
import { describe, it, expect, afterEach } from 'vitest'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import SourceEditor, { type SourceEditorApi } from '../src/components/SourceEditor'
let root: Root | null = null
afterEach(() => {
act(() => root?.unmount())
root = null
document.body.innerHTML = ''
})
describe('SourceEditor', () => {
it('挂载 CodeMirror 并提供锚点/跳行 API', async () => {
const md = '# 标题\n\n正文段落。\n'
let api: SourceEditorApi | null = null
const changes: string[] = []
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root!.render(
<SourceEditor
tabId="t1"
initialText={md}
initialCursorOffset={md.indexOf('正文')}
dark={false}
onChange={(t) => changes.push(t)}
onReady={(a) => { api = a }}
/>,
)
})
expect(container.querySelector('.cm-editor')).not.toBeNull()
expect(api).not.toBeNull()
expect(api!.getAnchor()).toEqual({ blockText: '正文段落。', offsetInBlock: 0 })
act(() => api!.jumpToLine(0))
expect(api!.getAnchor()?.blockText).toBe('标题')
})
})

154
tests/stage2.test.ts Normal file
View File

@@ -0,0 +1,154 @@
// 阶段 2 验收:标记显隐装饰、代码块 Tab、任务列表勾选 DOM、列表命令、纯文本插入、IME 守卫判定。
import { describe, it, expect, afterEach } from 'vitest'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
import { applyCodeBlockIndent } from '../src/editor/plugins'
import { editorCommands } from '../src/editor/commands'
import { insertPlainText } from '../src/editor/paste'
import { isImeComposing } from '../src/utils/ime'
let editors: Editor[] = []
afterEach(async () => {
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
editors = []
document.body.innerHTML = ''
})
async function load(md: string): Promise<Editor> {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, md)
editors.push(editor)
await editor.create()
return editor
}
function setSelection(editor: Editor, pos: number) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)))
})
}
function findTextPos(editor: Editor, text: string): number {
let found = -1
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.state.doc.descendants((node, pos) => {
if (found < 0 && node.isText && node.text?.includes(text)) {
found = pos + node.text.indexOf(text)
}
return found < 0
})
})
return found
}
function markers(editor: Editor): string[] {
let texts: string[] = []
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
texts = [...view.dom.querySelectorAll('.md-marker')].map((el) => el.textContent ?? '')
})
return texts
}
describe('语法标记显隐', () => {
it('光标在标题内显示 # 前缀,在粗体段内显示 **,移开后消失,且文档序列化不受影响', async () => {
const md = '# 大标题\n\n普通段落里的**粗体字**在这里。\n'
const editor = await load(md)
// 初始选区在文档开头(标题内)
expect(markers(editor).some((t) => t.includes('#'))).toBe(true)
const boldPos = findTextPos(editor, '粗体字')
expect(boldPos).toBeGreaterThan(0)
setSelection(editor, boldPos + 1)
const shown = markers(editor)
expect(shown).toContain('**')
expect(shown.some((t) => t.includes('#'))).toBe(false)
// 移到段落末尾的纯文本处,标记应全部隐藏
const endPos = findTextPos(editor, '在这里')
setSelection(editor, endPos + 2)
expect(markers(editor)).toEqual([])
// 装饰不进文档:序列化与原文一致(往返稳定)
expect(getMarkdown(editor)).toBe(md)
})
it('光标在引用内显示 > 前缀', async () => {
const editor = await load('前文。\n\n> 引用内容在此。\n')
const pos = findTextPos(editor, '引用内容')
setSelection(editor, pos + 1)
expect(markers(editor)).toContain('> ')
})
})
describe('代码块 Tab', () => {
it('代码块内 Tab 插入两空格,Shift+Tab 去掉', async () => {
const editor = await load('```ts\nconst a = 1;\n```\n')
const pos = findTextPos(editor, 'const')
setSelection(editor, pos)
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
expect(applyCodeBlockIndent(view as never, false)).toBe(true)
})
expect(getMarkdown(editor)).toContain(' const a = 1;')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
expect(applyCodeBlockIndent(view as never, true)).toBe(true)
})
expect(getMarkdown(editor)).toContain('const a = 1;')
expect(getMarkdown(editor)).not.toContain(' const')
})
it('普通段落里不处理 Tab', async () => {
const editor = await load('普通段落。\n')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
expect(applyCodeBlockIndent(view as never, false)).toBe(false)
})
})
})
describe('任务列表与列表命令', () => {
it('任务列表渲染出可点击 checkbox,点击切换并序列化回 [x]', async () => {
const editor = await load('- [ ] 待办事项\n- [x] 已完成\n')
let firstBox: HTMLInputElement | null = null
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const boxes = view.dom.querySelectorAll('input[type="checkbox"]')
expect(boxes.length).toBe(2)
expect((boxes[1] as HTMLInputElement).checked).toBe(true)
firstBox = boxes[0] as HTMLInputElement
})
firstBox!.click()
expect(getMarkdown(editor)).toMatch(/-\s\[[xX]\]\s待办事项/)
expect(getMarkdown(editor)).toMatch(/-\s\[[xX]\]\s已完成/)
})
it('段落转无序列表命令生效', async () => {
const editor = await load('变成列表项\n')
setSelection(editor, 2)
editorCommands.bulletList(editor)
expect(getMarkdown(editor)).toContain('- 变成列表项')
})
})
describe('纯文本插入与 IME 守卫', () => {
it('insertPlainText 在选区处插入并可撤销序列保留', async () => {
const editor = await load('前后\n')
setSelection(editor, 2)
expect(insertPlainText(editor, '中间')).toBe(true)
expect(getMarkdown(editor)).toContain('前中间后')
})
it('isImeComposing 判定', () => {
expect(isImeComposing({ isComposing: true })).toBe(true)
expect(isImeComposing({ keyCode: 229 })).toBe(true)
expect(isImeComposing({ isComposing: false, keyCode: 13 })).toBe(false)
expect(isImeComposing({})).toBe(false)
})
})

190
tests/stage3.test.ts Normal file
View File

@@ -0,0 +1,190 @@
// 阶段 3 验收:表格操作、数学公式、Mermaid 视图、脚注/HTML 转正、图片命令、附件链接。
import { describe, it, expect, afterEach } from 'vitest'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
import { editorCommands, insertMathBlock, insertMermaidBlock } from '../src/editor/commands'
import { insertLinkText } from '../src/editor/paste'
import { selectionInTable, isLastTableCell } from '../src/editor/plugins'
let editors: Editor[] = []
afterEach(async () => {
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
editors = []
document.body.innerHTML = ''
})
async function load(md: string): Promise<Editor> {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, md)
editors.push(editor)
await editor.create()
return editor
}
async function roundtrip(md: string): Promise<string> {
const once = getMarkdown(await load(md))
const twice = getMarkdown(await load(once))
expect(twice).toBe(once)
return once
}
function setSelection(editor: Editor, pos: number) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)))
})
}
function findTextPos(editor: Editor, text: string): number {
let found = -1
editor.action((ctx) => {
ctx.get(editorViewCtx).state.doc.descendants((node, pos) => {
if (found < 0 && node.isText && node.text?.includes(text)) found = pos + node.text.indexOf(text)
return found < 0
})
})
return found
}
const TABLE_MD = '| 名称 | 数量 |\n| :--- | ---: |\n| 苹果 | 3 |\n| 香蕉 | 5 |\n'
describe('表格', () => {
it('插入 3×3 表格命令生成表头与数据行', async () => {
const editor = await load('正文。\n')
setSelection(editor, 2)
editorCommands.insertTable(editor, 3, 3)
const md = getMarkdown(editor)
expect(md).toContain('|')
expect(md.split('\n').filter((l) => l.includes('|')).length).toBeGreaterThanOrEqual(4)
})
it('光标在单元格内可判定表格上下文与末格', async () => {
const editor = await load(TABLE_MD)
const pos = findTextPos(editor, '香蕉')
setSelection(editor, pos)
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
expect(selectionInTable(view.state.selection as never)).toBe(true)
// “香蕉”在最后一行第一列,不是末格;其右邻“5”所在格是末格
expect(isLastTableCell(view.state as never)).toBe(false)
})
const lastPos = findTextPos(editor, '5')
setSelection(editor, lastPos)
editor.action((ctx) => {
expect(isLastTableCell(ctx.get(editorViewCtx).state as never)).toBe(true)
})
})
it('加行/加列/删列改变结构且往返稳定', async () => {
const editor = await load(TABLE_MD)
setSelection(editor, findTextPos(editor, '苹果'))
editorCommands.addRowAfter(editor)
let md = getMarkdown(editor)
expect(md.split('\n').filter((l) => l.startsWith('|')).length).toBe(5) // 表头+分隔+3 数据行
editorCommands.addColAfter(editor)
md = getMarkdown(editor)
expect(md.split('\n')[0].split('|').length).toBeGreaterThanOrEqual(5) // 3 列
editorCommands.deleteCol(editor)
const twice = getMarkdown(await load(getMarkdown(editor)))
expect(twice).toBe(getMarkdown(editor))
})
it('对齐命令改变列对齐并保留到序列化', async () => {
const editor = await load(TABLE_MD)
setSelection(editor, findTextPos(editor, '苹果'))
editorCommands.setAlign(editor, 'center')
const md = getMarkdown(editor)
expect(md).toMatch(/:-+:/)
})
})
describe('数学公式(KaTeX 插件)', () => {
it('行内公式往返保留 LaTeX', async () => {
const once = await roundtrip('质能方程 $E = mc^2$ 是物理学常识。\n')
expect(once).toContain('E = mc^2')
expect(once).toContain('$')
})
it('块级公式往返保留', async () => {
const once = await roundtrip('$$\n\\frac{a}{b} + \\sqrt{x}\n$$\n')
expect(once).toContain('\\frac{a}{b}')
expect(once).toContain('$$')
})
it('插入公式块命令产出 $$ 块', async () => {
const editor = await load('正文。\n')
setSelection(editor, 2)
insertMathBlock(editor)
expect(getMarkdown(editor)).toContain('$$')
expect(getMarkdown(editor)).toContain('E = mc^2')
})
})
describe('Mermaid 与代码块视图', () => {
it('Mermaid 块渲染出节点视图容器,源码保留', async () => {
const source = '```mermaid\ngraph TD\n A[开始] --> B[结束]\n```\n'
const editor = await load(source)
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
expect(view.dom.querySelector('.mermaid-block')).not.toBeNull()
})
expect(getMarkdown(editor)).toContain('graph TD')
})
it('普通代码块仍是 pre>code 且内容可序列化', async () => {
const editor = await load('```ts\nconst v = 42;\n```\n')
editor.action((ctx) => {
const code = ctx.get(editorViewCtx).dom.querySelector('pre code')
expect(code?.textContent).toContain('const v = 42;')
})
expect(getMarkdown(editor)).toContain('const v = 42;')
})
it('插入 Mermaid 命令产出 mermaid 代码块', async () => {
const editor = await load('正文。\n')
setSelection(editor, 2)
insertMermaidBlock(editor)
const md = getMarkdown(editor)
expect(md).toContain('```mermaid')
expect(md).toContain('graph TD')
})
})
describe('脚注与 HTML(阶段 0 探针转正)', () => {
it('脚注定义与引用往返稳定', async () => {
const once = await roundtrip('正文带脚注[^1],还有第二个[^note]。\n\n[^1]: 第一条脚注。\n\n[^note]: 第二条脚注。\n')
expect(once).toContain('[^1]')
expect(once).toContain('第一条脚注')
expect(once).toContain('[^note]')
})
it('行内与块级 HTML 往返保留', async () => {
const once = await roundtrip('一段<u>下划线</u>文本。\n\n<div class="note">原始 HTML 块</div>\n')
expect(once).toContain('<u>下划线</u>')
expect(once).toContain('原始 HTML 块')
})
})
describe('图片与附件', () => {
it('图片命令插入相对路径图片并往返保留', async () => {
const editor = await load('看图:\n')
setSelection(editor, 3)
editorCommands.image(editor, '文章.assets/image-1.png', '示意图')
const md = getMarkdown(editor)
expect(md).toContain('文章.assets/image-1.png')
const twice = getMarkdown(await load(md))
expect(twice).toBe(md)
})
it('附件以链接形式插入并往返保留', async () => {
const editor = await load('附件:\n')
setSelection(editor, 3)
expect(insertLinkText(editor, '说明.pdf', '文档.assets/说明.pdf')).toBe(true)
const md = getMarkdown(editor)
expect(md).toContain('说明.pdf')
expect(md).toContain('文档.assets/')
})
})

53
tests/tabs.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { tabsReducer, createTab, isDirty, type TabsState } from '../src/stores/tabs'
function stateWith(contents: string[]): TabsState {
const tabs = contents.map((c, i) => createTab({ title: `文档${i + 1}`, content: c }))
return { tabs, activeId: tabs[0]?.id ?? null }
}
describe('标签页会话模型', () => {
it('同路径文档重复打开只激活已有标签', () => {
const a = createTab({ title: 'a.md', path: '/x/a.md', content: 'A' })
let s: TabsState = { tabs: [a], activeId: a.id }
s = tabsReducer(s, { type: 'open', tab: createTab({ title: 'a.md', path: '/x/a.md', content: 'A2' }) })
expect(s.tabs).toHaveLength(1)
expect(s.activeId).toBe(a.id)
})
it('dirty 由 content 与 savedContent 比较得出,保存后清除,改回原样也清除', () => {
const t = createTab({ content: '原文' })
expect(isDirty(t)).toBe(false)
let s: TabsState = { tabs: [t], activeId: t.id }
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '原文改' })
expect(isDirty(s.tabs[0])).toBe(true)
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '原文' })
expect(isDirty(s.tabs[0])).toBe(false)
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '新版' })
s = tabsReducer(s, { type: 'saved', id: t.id })
expect(isDirty(s.tabs[0])).toBe(false)
expect(s.tabs[0].savedContent).toBe('新版')
})
it('关闭活动标签后激活相邻标签;关闭其他/右侧范围正确', () => {
let s = stateWith(['a', 'b', 'c'])
const [t1, t2, t3] = s.tabs
s = tabsReducer(s, { type: 'activate', id: t2.id })
s = tabsReducer(s, { type: 'close', id: t2.id })
expect(s.tabs.map((t) => t.id)).toEqual([t1.id, t3.id])
expect(s.activeId).toBe(t3.id)
s = tabsReducer(s, { type: 'open', tab: createTab({ title: 'd', content: 'd' }) })
const t4 = s.tabs[2]
s = tabsReducer(s, { type: 'closeRight', id: t1.id })
expect(s.tabs.map((t) => t.id)).toEqual([t1.id])
void t4
})
it('拖拽排序 move 到目标下标', () => {
let s = stateWith(['a', 'b', 'c'])
const [t1, , t3] = s.tabs
s = tabsReducer(s, { type: 'move', id: t1.id, toIndex: 2 })
expect(s.tabs[2].id).toBe(t1.id)
expect(s.tabs[0].id).toBe(t3.id === s.tabs[0].id ? t3.id : s.tabs[0].id)
})
})

39
tests/text.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { countWords, countChars, extractOutline, baseName, isMarkdownPath } from '../src/utils/text'
describe('字数统计', () => {
it('中文按字、英文按词、标点不计', () => {
expect(countWords('你好,世界!')).toBe(4)
expect(countWords('hello world, again')).toBe(3)
expect(countWords('中文 mixed 文本 test')).toBe(2 + 1 + 2 + 1)
})
it('代码块可排除', () => {
const md = '正文一二\n\n```\ncode code code\n```\n'
expect(countWords(md, true)).toBe(4 + 3)
expect(countWords(md, false)).toBe(4)
})
it('字符数按 Unicode 码点计', () => {
expect(countChars('a你好b')).toBe(4)
})
})
describe('大纲提取', () => {
it('提取层级与行号,跳过代码块内的 # 行', () => {
const md = '# 一级\n\n```\n# 不是标题\n```\n\n## 二级\n正文\n### 三级\n'
const outline = extractOutline(md)
expect(outline.map((o) => o.text)).toEqual(['一级', '二级', '三级'])
expect(outline.map((o) => o.level)).toEqual([1, 2, 3])
expect(outline[1].line).toBe(6)
})
})
describe('路径工具', () => {
it('Windows 路径取文件名', () => {
expect(baseName('C:\\docs\\笔记\\a.md')).toBe('a.md')
expect(baseName('/home/x/b.md')).toBe('b.md')
})
it('Markdown 路径判定', () => {
expect(isMarkdownPath('a.MD')).toBe(true)
expect(isMarkdownPath('a.txt')).toBe(false)
})
})

1
tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{"compilerOptions":{"esModuleInterop":true,"isolatedModules":true,"jsx":"react-jsx","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"bundler","noEmit":true,"skipLibCheck":true,"strict":true,"target":"ES2022","types":["vitest/globals"]},"include":["src","tests","vite.config.ts","vitest.config.ts"]}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Tauri 约定:固定 1420 端口,devUrl 与此对应;src-tauri 变更不触发前端热更新。
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: { ignored: ['**/src-tauri/**'] },
},
build: { target: ['chrome105', 'safari15'] },
})

4
vitest.config.ts Normal file
View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], testTimeout: 20000 },
})