feat(editor): add themed callouts and desktop command boundary

This commit is contained in:
2026-09-06 11:33:19 +08:00
parent fcc319d5e1
commit 415efc4444
26 changed files with 759 additions and 13 deletions
@@ -13,6 +13,8 @@ import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown'
import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -51,6 +53,45 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('renders and folds callouts without losing portable Markdown on serialization', async () => {
const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(wrapper.findAll('.markdown-callout')).toHaveLength(2)
expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('true')
await wrapper.get('.callout-title').trigger('click')
expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('false')
const markdown = editor.action(getMarkdown())
expect(markdown.trim()).toBe(source)
})
it('dispatches native-ready commands through editor transactions and rejects invalid parameters', async () => {
useEditorStore().currentFilePath = 'test.md'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'text' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(await executeEditorCommand('editor.heading', 8)).toEqual({ ok: false, reason: 'invalid-params' })
expect(await executeEditorCommand('editor.heading', 2)).toEqual({ ok: true })
expect(editor.action(getMarkdown())).toContain('## text')
expect(await executeEditorCommand('editor.callout', { type: 'tip', body: '**test**' })).toEqual({ ok: true })
expect(wrapper.find('.markdown-callout').exists()).toBe(true)
useEditorStore().saveStatus = 'conflict'
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
})
it('keeps code examples as ordinary quotes and renders newly typed markers', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '> `[!NOTE]`\n\n> text' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(wrapper.find('.markdown-callout').exists()).toBe(false)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
let position = 0
view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'text') position = pos })
view.dispatch(view.state.tr.insertText('[!TIP]', position, position + 4))
})
expect(wrapper.find('.markdown-callout').exists()).toBe(true)
expect(editor.action(getMarkdown())).toContain('[!TIP]')
})
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. 第二',
@@ -28,7 +28,9 @@ import {
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark'
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
import { commandsCtx, editorViewCtx, parserCtx } from '@milkdown/kit/core'
import { Slice } from '@milkdown/kit/prose/model'
import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService'
import { TextSelection } from '@milkdown/kit/prose/state'
import { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue'
@@ -37,6 +39,8 @@ import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import { inlineCodeInputPlugin } from './inlineCodeInput'
import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin'
import { calloutTypes } from '@/utils/callouts'
import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
@@ -67,6 +71,61 @@ const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
let disposeCommands: (() => void) | undefined
let disposed = false
function insertMarkdown(source: string) {
crepe?.editor.action(ctx => {
const doc = ctx.get(parserCtx)(source)
if (!doc) throw new Error('Invalid Markdown')
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView())
view.focus()
})
}
function insertCallout(event: Event) {
const select = event.target as HTMLSelectElement
if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`)
select.value = ''
}
function installCommands() {
const targetPath = editorStore.currentFilePath
const handlers: Partial<Record<EditorCommandId, CommandHandler>> = {}
const toolbar: ToolbarCommand[] = ['bold', 'italic', 'ordered-list', 'bullet-list', 'inline-code', 'code-block', 'inline-math', 'math-block']
for (const command of toolbar) handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } }
handlers['editor.paragraph'] = () => { crepe!.editor.action(callCommand(turnIntoTextCommand.key)); return { ok: true } }
handlers['editor.heading'] = params => {
if (!Number.isInteger(params) || Number(params) < 1 || Number(params) > 6) return { ok: false, reason: 'invalid-params' }
crepe!.editor.action(callCommand(wrapInHeadingCommand.key, Number(params)))
return { ok: true }
}
handlers['editor.font-size'] = params => {
if (typeof params !== 'number' || !Number.isFinite(params) || params < 8 || params > 96) return { ok: false, reason: 'invalid-params' }
fontSizeInput.value = params
applyFontSizeValue()
return { ok: true }
}
handlers['editor.insert-markdown'] = params => {
if (typeof params !== 'string' || !params.trim() || params.length > 100000) return { ok: false, reason: 'invalid-params' }
insertMarkdown(params)
return { ok: true }
}
handlers['editor.callout'] = params => {
if (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' }
const { type, title = '', body = '', fold = '' } = params as Record<string, unknown>
if (typeof type !== 'string' || !/^[\w-]{1,64}$/.test(type) || typeof title !== 'string' || /[\r\n]/.test(title)
|| typeof body !== 'string' || !['', '+', '-'].includes(String(fold)) || title.length + body.length > 100000) return { ok: false, reason: 'invalid-params' }
insertMarkdown(`> [!${type}]${fold} ${title}\n${body.split(/\r?\n/).map(line => `> ${line}`).join('\n')}`)
return { ok: true }
}
disposeCommands = registerEditorCommands({
available: () => !loading.value && !!crepe && editorStore.mode === 'wysiwyg' && editorStore.saveStatus !== 'conflict'
&& !!targetPath && targetPath === editorStore.currentFilePath && crepe.editor.action(ctx => ctx.get(editorViewCtx).editable),
handlers,
})
}
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
for (const [id, entry] of diagramPreviews) {
@@ -251,6 +310,8 @@ onMounted(async () => {
})))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.editor.use(inlineCodeInputPlugin)
crepe.editor.use(calloutPlugin)
crepe.editor.config(configureCalloutSerialization)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
@@ -265,6 +326,7 @@ onMounted(async () => {
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
applyProofingPreferences()
loading.value = false
if (!disposed) installCommands()
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
@@ -282,7 +344,7 @@ watch(() => editorStore.headingRequest, request => {
})
})
onBeforeUnmount(() => { diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -324,6 +386,12 @@ defineExpose({ getEditor: () => crepe?.editor })
<button type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
<label class="toolbar-select">
<select :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
<option value="">{{ t('提示框', 'Callout') }}</option>
<option v-for="(_, type) in calloutTypes" :key="type" :value="type">{{ type }}</option>
</select>
</label>
</div>
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div class="milkdown-host" :class="{ loading }">
@@ -0,0 +1,89 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { parseCallout } from '@/utils/callouts'
import '@/styles/callouts.css'
import { remarkStringifyOptionsCtx, type Editor } from '@milkdown/kit/core'
export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ctx => {
ctx.update(remarkStringifyOptionsCtx, options => ({
...options,
handlers: { ...options.handlers, blockquote(node, _parent, state, info) {
const exit = state.enter('blockquote')
const tracker = state.createTracker(info)
tracker.move('> ')
tracker.shift(2)
const result = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => `>${blank ? '' : ' '}${line}`)
exit()
// Only remove escaping from a leading callout marker, never body literals.
return result.replace(/^(> )\\\[!([\w-]+)\\?\]/, '$1[!$2]')
} },
}))
}
// Keep native blockquotes in the document: typing, undo and Markdown serialization
// remain Milkdown transactions; the view never rewrites a user's callout source.
export const calloutPlugin = $prose(() => new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = []
state.doc.descendants((node, position) => {
if (node.type.name !== 'blockquote' || node.firstChild?.type.name !== 'paragraph' || node.firstChild.firstChild?.marks.length) return
const callout = parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n'))
if (!callout) return
const from = position + 2
const to = from + callout.markerLength
const editing = state.selection.from <= to && state.selection.to >= from
decorations.push(Decoration.inline(from, to, { class: editing ? 'callout-marker-editing' : 'callout-marker' }))
})
return DecorationSet.create(state.doc, decorations)
},
nodeViews: {
blockquote(initialNode) {
const dom = document.createElement('blockquote')
const header = document.createElement('button')
header.type = 'button'
header.className = 'callout-title'
header.contentEditable = 'false'
const contentDOM = document.createElement('div')
contentDOM.className = 'callout-body'
dom.append(header, contentDOM)
let signature = ''
let foldable = false
const update = (node: typeof initialNode) => {
if (node.type.name !== 'blockquote') return false
const callout = node.firstChild?.type.name === 'paragraph' && !node.firstChild.firstChild?.marks.length
? parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n')) : null
dom.className = callout ? 'markdown-callout' : ''
header.hidden = !callout
foldable = !!callout?.fold
header.disabled = !foldable
if (callout) {
dom.dataset.callout = callout.type
const next = `${callout.name}:${callout.fold}`
if (signature !== next) dom.dataset.collapsed = String(callout.fold === '-')
signature = next
header.textContent = `${foldable ? (dom.dataset.collapsed === 'true' ? '▸ ' : '▾ ') : ''}${callout.title}`
if (foldable) header.setAttribute('aria-expanded', String(dom.dataset.collapsed !== 'true'))
else header.removeAttribute('aria-expanded')
} else {
delete dom.dataset.callout
delete dom.dataset.collapsed
signature = ''
}
return true
}
let current = initialNode
header.onclick = () => {
if (!foldable) return
dom.dataset.collapsed = String(dom.dataset.collapsed !== 'true')
update(current)
}
update(initialNode)
return { dom, contentDOM, update(node) { current = node; return update(node) },
stopEvent: event => header.contains(event.target as Node),
ignoreMutation: mutation => mutation.type !== 'selection' && !contentDOM.contains(mutation.target) }
},
},
},
}))