feat(editor): add themed callouts and desktop command boundary
This commit is contained in:
@@ -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) }
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user