feat: 阶段1-3 桌面编辑器(Tauri 骨架/编辑核心深化/复杂内容)
This commit is contained in:
12
index.html
Normal file
12
index.html
Normal 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
6977
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
489
src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
src/components/EditorPane.tsx
Normal file
123
src/components/EditorPane.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/components/MenuBar.tsx
Normal file
71
src/components/MenuBar.tsx
Normal 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
96
src/components/Modals.tsx
Normal 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
121
src/components/Sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
src/components/SourceEditor.tsx
Normal file
88
src/components/SourceEditor.tsx
Normal 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} />
|
||||||
|
}
|
||||||
33
src/components/StatusBar.tsx
Normal file
33
src/components/StatusBar.tsx
Normal 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
64
src/components/TabBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/components/TableToolbar.tsx
Normal file
29
src/components/TableToolbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/TitleBar.tsx
Normal file
42
src/components/TitleBar.tsx
Normal 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
134
src/editor/commands.ts
Normal 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
49
src/editor/cursorSync.ts
Normal 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
137
src/editor/editor.ts
Normal 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
204
src/editor/nodeViews.ts
Normal 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
36
src/editor/paste.ts
Normal 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
251
src/editor/plugins.ts
Normal 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
7
src/main.tsx
Normal 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
141
src/services/platform.ts
Normal 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
114
src/stores/tabs.ts
Normal 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
149
src/styles.css
Normal 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
5
src/utils/ime.ts
Normal 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
44
src/utils/text.ts
Normal 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
35
tests/cursorSync.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
tests/editor-regression.test.ts
Normal file
60
tests/editor-regression.test.ts
Normal 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
16
tests/setup.ts
Normal 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
|
||||||
|
}
|
||||||
40
tests/sourceEditor.test.tsx
Normal file
40
tests/sourceEditor.test.tsx
Normal 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
154
tests/stage2.test.ts
Normal 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
190
tests/stage3.test.ts
Normal 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
53
tests/tabs.test.ts
Normal 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
39
tests/text.test.ts
Normal 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
1
tsconfig.json
Normal 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
14
vite.config.ts
Normal 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
4
vitest.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
export default defineConfig({
|
||||||
|
test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], testTimeout: 20000 },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user