fix(editor): handle paired inline code input and complete markdown rendering

This commit is contained in:
2026-09-05 23:14:19 +08:00
parent a1ab1024f0
commit 6107b7ff1b
10 changed files with 317 additions and 2 deletions
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX.
vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) })
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
@@ -49,6 +51,106 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('renders the supported format matrix and preserves inline code', async () => {
const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6',
'正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二',
'- [x] 完成\n- [ ] 未完成', '[链接](https://example.com)',
'| A | B |\n| --- | --- |\n| x | y |', '---', '$x^2$', '$$\nx^2\n$$', '```js\nconst n = 1\n```'].join('\n\n')
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:source},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(wrapper.get('.ProseMirror code').text()).toBe('s')
for (const selector of ['h1','h2','h3','h4','h5','h6','strong','em','del','blockquote','ol','ul','table','hr','a']) expect(wrapper.find(`.ProseMirror ${selector}`).exists(), selector).toBe(true)
expect(editor.action(getMarkdown())).toContain('`s`')
expect(editor.action(getMarkdown())).toContain('``a`b``')
})
it('converts a typed closing backtick to inline code', async () => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
for (const text of '`s`') {
const {from,to} = view.state.selection
let handled = false
view.someProp('handleTextInput', handler => { if (handler(view,from,to,text, () => view.state.tr.insertText(text,from,to))) { handled = true; return true } })
if (!handled) view.dispatch(view.state.tr.insertText(text,from,to))
}
})
await wrapper.vm.$nextTick()
expect(wrapper.get('.ProseMirror code').text()).toBe('s')
})
it('reconciles IME composition text without handleTextInput', async () => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'})
await new Promise(resolve => setTimeout(resolve, 30))
expect(wrapper.get('.ProseMirror code').text()).toBe('s')
expect(editor.action(getMarkdown()).trim()).toBe('`s`')
})
it.each(['insertText', 'insertCompositionText', 'insertReplacementText'])('reconciles %s without event.data after the DOM update', async (inputType) => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
// Chromium/IME can omit data and commit its DOM change after the input event.
await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
await new Promise(resolve => setTimeout(resolve, 10))
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
})
it('waits for composition cleanup before converting committed text', async () => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const view = editor.action(ctx => ctx.get(editorViewCtx))
const composing = vi.spyOn(view, 'composing', 'get').mockReturnValue(true)
await wrapper.get('.ProseMirror').trigger('compositionstart')
view.dispatch(view.state.tr.insertText('`s`'))
await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'})
await new Promise(resolve => setTimeout(resolve, 50))
expect(wrapper.find('.ProseMirror code').exists()).toBe(false)
composing.mockReturnValue(false)
await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
composing.mockRestore()
})
it('converts content typed between an existing pair of backticks', async () => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const view = editor.action(ctx => ctx.get(editorViewCtx))
view.dispatch(view.state.tr.insertText('``'))
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'`'})
await new Promise(resolve => setTimeout(resolve, 60))
// Empty pairs are serialized as escaped literal text, but that must not
// prevent recognition after the user moves back and fills in the content.
expect(editor.action(getMarkdown())).toContain('\\`')
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)).insertText('s'))
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'s'})
await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
expect(editor.action(getMarkdown()).trim()).toBe('`s`')
expect(view.state.selection.from).toBe(2)
view.dispatch(view.state.tr.insertText('tring'))
expect(editor.action(getMarkdown()).trim()).toBe('`string`')
})
it.each(['insertFromPaste', 'historyUndo', 'deleteContentBackward'])('does not reinterpret literals on %s', async (inputType) => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
await new Promise(resolve => setTimeout(resolve, 60))
expect(wrapper.find('.ProseMirror code').exists()).toBe(false)
})
it('enables inline code from the toolbar at an empty selection', async () => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
await wrapper.get('[aria-label="行内代码"]').trigger('pointerdown')
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('value')))
expect(editor.action(getMarkdown()).trim()).toBe('`value`')
})
it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body })
mounted.push(wrapper)
@@ -33,6 +33,7 @@ import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import { inlineCodeInputPlugin } from './inlineCodeInput'
import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
@@ -93,6 +94,16 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli
function runCommand(command: ToolbarCommand) {
const editor = crepe?.editor
if (!editor) return
if (command === 'inline-code' && editor.action(ctx => ctx.get(editorViewCtx).state.selection.empty)) {
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
const mark = view.state.schema.marks.inlineCode!
const active = (view.state.storedMarks ?? view.state.selection.$from.marks()).some(item => item.type === mark)
view.dispatch(active ? view.state.tr.removeStoredMark(mark) : view.state.tr.setStoredMarks([mark.create()]))
view.focus()
})
return
}
// 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。
const actions = {
bold: callCommand(toggleStrongCommand.key),
@@ -228,6 +239,7 @@ onMounted(async () => {
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.editor.use(inlineCodeInputPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
@@ -404,6 +416,7 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-markdown-marker); font-weight: 700; }
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-markdown-marker); }
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
.milkdown-host :deep(.ProseMirror :not(pre) > code) { padding: .12em .35em; border: 1px solid var(--color-code-border); border-radius: var(--radius-sm); background: var(--color-code-background); color: var(--color-code-text); font-size: .9em; box-decoration-break: clone; }
:global([data-theme='dark'] .milkdown-host .milkdown) { color-scheme: dark; }
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
</style>
@@ -0,0 +1,99 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin } from '@milkdown/kit/prose/state'
import type { EditorView } from '@milkdown/kit/prose/view'
function reconcile(view: EditorView) {
if (view.isDestroyed || view.composing || !view.state.selection.empty) return
const { $from } = view.state.selection
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc')
// Also inspect the closing delimiter AFTER the caret: users commonly type
// a pair of backticks first, move left, and then fill in the code.
const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
let candidate: { start: number; end: number } | undefined
for (const match of text.matchAll(spans)) {
const start = match.index! + match[1]!.length
const end = match.index! + match[0].length
if (match[2]!.trim() && $from.parentOffset > start && $from.parentOffset <= end) {
candidate = { start: $from.start() + start, end: $from.start() + end }
break
}
}
if (!candidate) return
const mark = view.state.schema.marks.inlineCode
if (!mark) return
const { start, end } = candidate
if (view.state.doc.rangeHasMark(start, end, mark)) return
const tr = view.state.tr.delete(end - 1, end).delete(start, start + 1)
tr.removeMark(start, end - 2).addMark(start, end - 2, mark.create())
// Filling an existing pair must keep subsequent letters inside code. Typing
// the closing delimiter explicitly should instead leave code as usual.
if ($from.pos < end) tr.setStoredMarks([mark.create()])
else tr.removeStoredMark(mark)
view.dispatch(tr)
}
// DOM input can bypass handleTextInput (IME, replacement text, missing event.data).
// Observe it in capture phase, then wait for ProseMirror's DOM observer and its
// composition cleanup before inspecting the document. Never reconcile on load,
// paste, undo, or a selection change alone.
export const inlineCodeInputPlugin = $prose(() => new Plugin({
view(view) {
let timer: ReturnType<typeof setTimeout> | undefined
let pending = false
let composing = false
let attempts = 0
const cancel = () => { clearTimeout(timer); pending = false }
const schedule = () => {
clearTimeout(timer)
if (!pending || composing) return
timer = setTimeout(() => {
if (view.isDestroyed) return cancel()
if (view.composing) {
if (++attempts < 10) schedule()
else cancel()
return
}
pending = false
reconcile(view)
}, 30)
}
const input = (event: Event) => {
const type = (event as InputEvent).inputType
if (type && (!type.startsWith('insert') || /paste|drop/i.test(type))) return cancel()
pending = true
attempts = 0
schedule()
}
const start = () => { composing = true; cancel() }
const end = () => { composing = false; pending = true; attempts = 0; schedule() }
const keydown = (event: KeyboardEvent) => {
// ProseMirror handles undo/paste itself, so those actions need not emit input.
if (event.ctrlKey || event.metaKey || ['Backspace', 'Delete', 'Escape'].includes(event.key)) cancel()
else if (pending && !composing && !view.composing && event.key === 'Enter') {
cancel()
reconcile(view)
}
}
view.dom.addEventListener('input', input, true)
view.dom.addEventListener('keydown', keydown, true)
view.dom.addEventListener('paste', cancel, true)
view.dom.addEventListener('drop', cancel, true)
view.dom.addEventListener('compositionstart', start, true)
view.dom.addEventListener('compositionend', end, true)
return {
update(current, previous) {
if (pending && !current.state.doc.eq(previous.doc)) schedule()
},
destroy() {
cancel()
view.dom.removeEventListener('input', input, true)
view.dom.removeEventListener('keydown', keydown, true)
view.dom.removeEventListener('paste', cancel, true)
view.dom.removeEventListener('drop', cancel, true)
view.dom.removeEventListener('compositionstart', start, true)
view.dom.removeEventListener('compositionend', end, true)
},
}
},
}))
+1 -1
View File
@@ -80,7 +80,7 @@ async function openResult(result: SearchResult) {
.search-history { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-md); }
.advanced { grid-column: 1 / -1; }
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
.results-header { margin-block: var(--space-xl) var(--space-md); color: var(--color-text-secondary); }
.result-list { display: grid; gap: var(--space-md); }
.result-card { position: relative; cursor: pointer; overflow: hidden; }
.result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); }