(null)
+
+ if (tabs.length === 0) return null
+
+ return (
+ setMenuFor(null)}>
+ {tabs.map((tab, index) => (
+
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}
+ >
+ {tab.title}
+ {isDirty(tab) && ●}
+
+
+ ))}
+
+ {menuFor && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/TableToolbar.tsx b/src/components/TableToolbar.tsx
new file mode 100644
index 0000000..6a0c650
--- /dev/null
+++ b/src/components/TableToolbar.tsx
@@ -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) => (
+
+ )
+ return (
+
+ {btn('+行', () => editorCommands.addRowAfter(editor), '在下方新增行')}
+ {btn('+列', () => editorCommands.addColAfter(editor), '在右侧新增列')}
+ {btn('删行', () => editorCommands.deleteRow(editor), '删除当前行')}
+ {btn('删列', () => editorCommands.deleteCol(editor), '删除当前列')}
+
+ {btn('左齐', () => editorCommands.setAlign(editor, 'left'), '列左对齐')}
+ {btn('居中', () => editorCommands.setAlign(editor, 'center'), '列居中')}
+ {btn('右齐', () => editorCommands.setAlign(editor, 'right'), '列右对齐')}
+
+ {btn('删表', () => editorCommands.deleteTable(editor), '删除整个表格')}
+
+ )
+}
diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx
new file mode 100644
index 0000000..01db0ff
--- /dev/null
+++ b/src/components/TitleBar.tsx
@@ -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 (
+
+ M
+
+ {docTitle}
+ {dirty && ●}
+ — new-markdown
+
+ {tauri && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/editor/commands.ts b/src/editor/commands.ts
new file mode 100644
index 0000000..9643d2a
--- /dev/null
+++ b/src/editor/commands.ts
@@ -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;此处统一擦除为 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)
+ })
+}
diff --git a/src/editor/cursorSync.ts b/src/editor/cursorSync.ts
new file mode 100644
index 0000000..85f9321
--- /dev/null
+++ b/src/editor/cursorSync.ts
@@ -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))
+}
diff --git a/src/editor/editor.ts b/src/editor/editor.ts
new file mode 100644
index 0000000..6038026
--- /dev/null
+++ b/src/editor/editor.ts
@@ -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
+}
+
+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())
+}
diff --git a/src/editor/nodeViews.ts b/src/editor/nodeViews.ts
new file mode 100644
index 0000000..3650b61
--- /dev/null
+++ b/src/editor/nodeViews.ts
@@ -0,0 +1,204 @@
+// 任务列表节点视图(阶段 2 补齐):
+// Kit 的 preset-gfm 只把任务项渲染为 ,没有可点击的复选框(那是 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 | 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],
+ ])
+ }
+}
diff --git a/src/editor/paste.ts b/src/editor/paste.ts
new file mode 100644
index 0000000..cbb0345
--- /dev/null
+++ b/src/editor/paste.ts
@@ -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
+}
diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts
new file mode 100644
index 0000000..755911b
--- /dev/null
+++ b/src/editor/plugins.ts
@@ -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 }
+const MARK_SPECS: Record = {
+ 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
+ isTextblock: boolean
+ isText?: boolean
+ text?: string
+ marks?: { type: { name: string }; attrs: Record }[]
+ 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
+ },
+ },
+ }))
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..aef8763
--- /dev/null
+++ b/src/main.tsx
@@ -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()
diff --git a/src/services/platform.ts b/src/services/platform.ts
new file mode 100644
index 0000000..9ba4f09
--- /dev/null
+++ b/src/services/platform.ts
@@ -0,0 +1,141 @@
+// 平台服务层:所有磁盘/对话框访问收口于此。
+// - Tauri 模式:走 Rust 命令(编码探测 + 原子写)与官方 dialog 插件;
+// - Web 降级模式(npm run dev 在浏览器里调 UI 时):文件打开用 读入,
+// 保存退化为下载。降级仅用于界面调试,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(cmd: string, args?: Record): Promise {
+ const { invoke } = await import('@tauri-apps/api/core')
+ return invoke(cmd, args)
+}
+
+export async function pickOpenFile(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ return invoke('read_text_file', { path })
+}
+
+export async function writeTextFile(path: string, content: string, keepBom: boolean): Promise {
+ await invoke('write_text_file', { path, content, keepBom })
+}
+
+export async function listDir(path: string): Promise {
+ return invoke('list_dir', { path })
+}
+
+async function pickFileWithFilters(filters: { name: string; extensions: string[] }[]): Promise {
+ 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 {
+ return pickFileWithFilters([{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'] }])
+}
+
+export function pickAnyFile(): Promise {
+ return pickFileWithFilters([{ name: '全部文件', extensions: ['*'] }])
+}
+
+/** 把外部文件复制进文档资源目录,返回相对路径(阶段 3 图片/附件落盘)。 */
+export async function copyToAssets(source: string, docPath: string): Promise {
+ return invoke('copy_to_assets', { source, docPath })
+}
+
+/** 把字节(粘贴/拖入的图片)写入文档资源目录,返回相对路径。 */
+export async function saveBytesToAssets(docPath: string, fileName: string, bytes: number[]): Promise {
+ return invoke('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)
+}
diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts
new file mode 100644
index 0000000..a94f904
--- /dev/null
+++ b/src/stores/tabs.ts
@@ -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 & { 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
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..c3e216d
--- /dev/null
+++ b/src/styles.css
@@ -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; }
diff --git a/src/utils/ime.ts b/src/utils/ime.ts
new file mode 100644
index 0000000..b442003
--- /dev/null
+++ b/src/utils/ime.ts
@@ -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
+}
diff --git a/src/utils/text.ts b/src/utils/text.ts
new file mode 100644
index 0000000..1f13538
--- /dev/null
+++ b/src/utils/text.ts
@@ -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)
+}
diff --git a/tests/cursorSync.test.ts b/tests/cursorSync.test.ts
new file mode 100644
index 0000000..a86d585
--- /dev/null
+++ b/tests/cursorSync.test.ts
@@ -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)
+ })
+})
diff --git a/tests/editor-regression.test.ts b/tests/editor-regression.test.ts
new file mode 100644
index 0000000..652f488
--- /dev/null
+++ b/tests/editor-regression.test.ts
@@ -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 {
+ 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')
+ })
+})
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..ff6ef8a
--- /dev/null
+++ b/tests/setup.ts
@@ -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
+}
diff --git a/tests/sourceEditor.test.tsx b/tests/sourceEditor.test.tsx
new file mode 100644
index 0000000..8d71d9f
--- /dev/null
+++ b/tests/sourceEditor.test.tsx
@@ -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(
+ 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('标题')
+ })
+})
diff --git a/tests/stage2.test.ts b/tests/stage2.test.ts
new file mode 100644
index 0000000..2917fc2
--- /dev/null
+++ b/tests/stage2.test.ts
@@ -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 {
+ 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)
+ })
+})
diff --git a/tests/stage3.test.ts b/tests/stage3.test.ts
new file mode 100644
index 0000000..16246ed
--- /dev/null
+++ b/tests/stage3.test.ts
@@ -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 {
+ 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 {
+ 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('一段下划线文本。\n\n原始 HTML 块
\n')
+ expect(once).toContain('下划线')
+ 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/')
+ })
+})
diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts
new file mode 100644
index 0000000..7d5d9af
--- /dev/null
+++ b/tests/tabs.test.ts
@@ -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)
+ })
+})
diff --git a/tests/text.test.ts b/tests/text.test.ts
new file mode 100644
index 0000000..d742237
--- /dev/null
+++ b/tests/text.test.ts
@@ -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)
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1a9ffd0
--- /dev/null
+++ b/tsconfig.json
@@ -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"]}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..93e9650
--- /dev/null
+++ b/vite.config.ts
@@ -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'] },
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..7369193
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,4 @@
+import { defineConfig } from 'vitest/config'
+export default defineConfig({
+ test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], testTimeout: 20000 },
+})