fix(editor): handle paired inline code input and complete markdown rendering
This commit is contained in:
@@ -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)
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user