Files
NotesAgentic/frontend/src/features/editor/VisualMarkdownEditor.vue
T

585 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link, Fold, Expand } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror'
import { keymap, EditorView as CodeEditorView } from '@codemirror/view'
import { indentUnit } from '@codemirror/language'
import { EditorState as CodeEditorState } from '@codemirror/state'
import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
toggleInlineCodeCommand,
toggleLinkCommand,
toggleStrongCommand,
turnIntoTextCommand,
wrapInBulletListCommand,
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark'
import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core'
import { Slice } from '@milkdown/kit/prose/model'
import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService'
import { TextSelection, Plugin } from '@milkdown/kit/prose/state'
import { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue'
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 { calloutPlugin, configureCalloutSerialization } from './calloutPlugin'
import { calloutTypes } from '@/utils/callouts'
import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSections } from './headingFolding'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>()
const headingAppearance = useHeadingAppearanceStore()
const markdownPreferences = { ...useMarkdownPreferencesStore().normalized }
const metadata = ref(splitNoteMetadata(props.initialContent))
const tagDraft = ref('')
function setTags(tags: string[]) {
if (!metadata.value || !crepe) return
const prefix = updateMetadataTags(metadata.value, tags)
const body = crepe.editor.action(getMarkdown())
metadata.value = splitNoteMetadata(prefix + body)
editorStore.updateContent(prefix + body)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
function addTags() {
const tags = tagDraft.value.split(/[,]/).map(tag => tag.trim()).filter(tag => tag && !/[\r\n"\\]/.test(tag))
if (!tags.length || !metadata.value) return
setTags([...metadata.value.tags, ...tags])
tagDraft.value = ''
}
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const allHeadingsFolded = ref(false)
const hasFoldableHeadings = ref(false)
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>> = {}
for (const [id, action] of [['editor.heading.toggle-fold', 'toggle'], ['editor.heading.fold-all', 'all'], ['editor.heading.unfold-all', 'none']] as const) {
handlers[id] = () => { foldHeadings(action); return { ok: true } }
}
const toolbar: ToolbarCommand[] = ['bold', 'italic', 'ordered-list', 'bullet-list', 'inline-code', 'code-block', 'inline-math', 'math-block']
for (const command of toolbar) {
if (!markdownPreferences.math && command.includes('math')) continue
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 (!markdownPreferences.callouts) return { ok: false, reason: 'unsupported' }
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,
})
}
function foldHeadings(action: 'toggle' | 'all' | 'none') {
crepe?.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
const tr = headingFoldTransaction(view.state, action)
if (tr) view.dispatch(tr)
})
}
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) {
if (entry.apply === apply) diagramPreviews.delete(id)
}
const element = createMermaidPreview(source, themeStore.isDark, apply)
diagramPreviews.set(element.dataset.previewId!, { source, apply })
return element
}
watch(() => themeStore.currentThemeId, () => {
const current = [...diagramPreviews.entries()]
diagramPreviews.clear()
for (const [id, entry] of current) {
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply))
}
}, { flush: 'post' })
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
if (!editable) return
editable.spellcheck = settingsStore.spellCheck
editable.setAttribute('spellcheck', String(settingsStore.spellCheck))
editable.lang = settingsStore.language
}
type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block'
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),
italic: callCommand(toggleEmphasisCommand.key),
'ordered-list': callCommand(wrapInOrderedListCommand.key),
'bullet-list': callCommand(wrapInBulletListCommand.key),
'inline-code': callCommand(toggleInlineCodeCommand.key),
'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage),
'inline-math': callCommand('ToggleLatex'),
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
}
editor.action(actions[command])
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
}
async function applyLink() {
if (!crepe) return
const editor = crepe
const snapshot = editor.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
return { doc: view.state.doc, selection: view.state.selection }
})
const href = (await askPrompt(t('请输入链接地址', 'Enter link address'), 'https://'))?.trim()
if (!href || crepe !== editor) return
const label = snapshot.selection.empty ? await askPrompt(t('请输入链接文字', 'Enter link text'), href) : ''
if (label === null || crepe !== editor) return
editor.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (!view.state.doc.eq(snapshot.doc)) return
view.dispatch(view.state.tr.setSelection(snapshot.selection))
const commands = ctx.get(commandsCtx)
if (view.state.selection.empty) {
const text = label.trim() || href
const from = view.state.selection.from
const transaction = view.state.tr.insertText(text, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + text.length))
view.dispatch(transaction)
}
return commands.call(toggleLinkCommand.key, { href })
})
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
}
function applyHeading(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (!value || !crepe) return
crepe.editor.action(value === 'paragraph'
? callCommand(turnIntoTextCommand.key)
: callCommand(wrapInHeadingCommand.key, Number(value)))
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
;(event.target as HTMLSelectElement).value = ''
}
function applyFontSize(event: Event) {
const size = Number((event.target as HTMLSelectElement).value)
if (!size || !crepe) return
fontSizeInput.value = size
applyFontSizeValue()
;(event.target as HTMLSelectElement).value = ''
}
function applyFontSizeValue() {
if (!crepe) return
const size = Math.min(96, Math.max(8, Math.round(Number(fontSizeInput.value))))
if (!Number.isFinite(size)) return
fontSizeInput.value = size
applyMarkdownFontSize(crepe.editor, size)
}
onMounted(async () => {
crepe = new Crepe({
root: editorRoot.value,
defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
previewOnlyByDefault: true,
previewToggleText: previewOnly => previewOnly ? t('编辑', 'Edit') : t('预览', 'Preview'),
previewLabel: t('图表预览', 'Preview'),
searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'),
copyText: t('复制', 'Copy'),
},
[Crepe.Feature.Latex]: {
inlineEditConfirm: t('确认', 'Confirm'),
},
[Crepe.Feature.LinkTooltip]: {
editButton: t('编辑', 'Edit'),
removeButton: t('移除', 'Remove'),
confirmButton: t('确认', 'Confirm'),
inputPlaceholder: t('粘贴链接地址…', 'Paste link address…'),
},
[Crepe.Feature.Toolbar]: {
boldLabel: t('加粗', 'Bold'),
italicLabel: t('斜体', 'Italic'),
strikethroughLabel: t('删除线', 'Strikethrough'),
codeLabel: t('行内代码', 'Inline code'),
latexLabel: t('行内公式', 'Inline formula'),
linkLabel: t('链接', 'Link'),
},
[Crepe.Feature.BlockEdit]: {
textGroup: {
label: t('文本', 'Text'),
text: { label: t('正文', 'Paragraph') },
h1: { label: t('一级标题', 'Heading 1') },
h2: { label: t('二级标题', 'Heading 2') },
h3: { label: t('三级标题', 'Heading 3') },
h4: { label: t('四级标题', 'Heading 4') },
h5: { label: t('五级标题', 'Heading 5') },
h6: { label: t('六级标题', 'Heading 6') },
quote: { label: t('引用', 'Quote') },
divider: { label: t('分割线', 'Divider') },
},
listGroup: {
label: t('列表', 'Lists'),
bulletList: { label: t('无序列表', 'Bullet list') },
orderedList: { label: t('有序列表', 'Ordered list') },
taskList: { label: t('任务列表', 'Task list') },
},
advancedGroup: {
label: t('插入', 'Insert'),
image: { label: t('图片', 'Image') },
codeBlock: { label: t('代码块', 'Code block') },
table: { label: t('表格', 'Table') },
math: { label: t('公式块', 'Formula block') },
},
},
},
})
// Crepe's defaultsDeep merges language arrays and theme extension internals.
// Replace both AFTER feature configuration to avoid default grammar collisions.
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
: config.renderPreview(language, content, applyPreview),
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme),
indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent),
...(markdownPreferences.wrapCode ? [CodeEditorView.lineWrapping] : [])],
})))
crepe.editor.config(ctx => ctx.update(remarkStringifyOptionsCtx, options => ({
...options, setext: markdownPreferences.heading === 'setext', bullet: markdownPreferences.bullet,
incrementListMarker: markdownPreferences.incrementList, fence: markdownPreferences.fence,
})))
if (!markdownPreferences.autoLinks) crepe.editor.use($remark('disable-bare-autolinks', () => () => (tree, file) => {
type Ast = { type: string; value?: string; url?: string; children?: Ast[]; position?: { start: { offset?: number }; end: { offset?: number } } }
const source = String(file.value)
const walk = (node: Ast) => {
if (node.type === 'link' && node.position) {
const raw = source.slice(node.position.start.offset, node.position.end.offset)
if (/^(?:https?:\/\/|www\.)\S+$/.test(raw)) {
node.type = 'text'; node.value = raw; delete node.children; delete node.url
}
}
node.children?.forEach(walk)
}
walk(tree as Ast)
}))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.editor.use(inlineCodeInputPlugin)
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
crepe.editor.use(headingFoldingPlugin)
crepe.editor.use($prose(() => new Plugin({
view(view) {
const sync = (current: typeof view) => {
const sections = headingSections(current.state.doc)
const folded = headingFoldKey.getState(current.state)
hasFoldableHeadings.value = sections.length > 0
// Hidden descendants retain their own state but are not visible expanded sections.
let hiddenUntil = -1
allHeadingsFolded.value = sections.length > 0 && sections.every(section => {
if (section.from < hiddenUntil) return true
if (!folded?.has(section.from)) return false
hiddenUntil = section.end
return true
})
}
sync(view)
return { update: sync }
},
})))
if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
const fullMarkdown = (metadata.value?.prefix ?? '') + markdown
if (markdown === previousMarkdown || fullMarkdown === editorStore.content) return
editorStore.updateContent(fullMarkdown)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
})
})
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
applyProofingPreferences()
loading.value = false
if (!disposed) installCommands()
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
watch(() => editorStore.headingRequest, request => {
if (!request || request.path !== editorStore.currentFilePath || !crepe) return
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
let index = 0
view.state.doc.forEach((node, offset) => {
if (node.type.name !== 'heading') return
if (index++ !== request.index) return
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1)).scrollIntoView())
view.focus()
})
})
})
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
<template>
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<div class="section-actions">
<button type="button" :disabled="loading || !hasFoldableHeadings"
:title="allHeadingsFolded ? t('展开所有章节正文', 'Expand all section content') : t('折叠所有章节,保留标题', 'Collapse all sections, keeping headings visible')"
:aria-label="allHeadingsFolded ? t('展开所有章节', 'Unfold all sections') : t('折叠所有章节', 'Fold all sections')"
@click="foldHeadings(allHeadingsFolded ? 'none' : 'all')">
<AppIcon :icon="allHeadingsFolded ? Expand : Fold" :size="16" />
<span>{{ allHeadingsFolded ? t('全部展开', 'Expand all') : t('全部折叠', 'Collapse all') }}</span>
</button>
</div>
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span>
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
<option value="" selected>{{ t('标题', 'Heading') }}</option>
<option value="paragraph">{{ t('正文', 'Paragraph') }}</option>
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
</select>
</label>
<button type="button" :title="t('加粗 (Ctrl+B)', 'Bold (Ctrl+B)')" :aria-label="t('加粗', 'Bold')" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button>
<button type="button" :title="t('斜体 (Ctrl+I)', 'Italic (Ctrl+I)')" :aria-label="t('斜体', 'Italic')" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button>
<span class="toolbar-divider" />
<button type="button" class="list-glyph" :title="t('有序列表', 'Ordered list')" :aria-label="t('有序列表', 'Ordered list')" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines"></span></button>
<button type="button" class="list-glyph" :title="t('无序列表', 'Bullet list')" :aria-label="t('无序列表', 'Bullet list')" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker"></span><span class="list-lines"></span></button>
<span class="toolbar-divider" />
<label class="toolbar-select font-size-select" :title="t('选择预设字号', 'Choose a preset font size')">
<span class="format-glyph font-size-glyph">A</span>
<select :aria-label="t('文字字号', 'Font size')" @change="applyFontSize">
<option value="" selected>{{ t('字号', 'Size') }}</option>
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
</select>
</label>
<div class="font-size-input" :title="t('输入字号后按 Enter 或点击应用', 'Enter a font size, then press Enter or Apply')">
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" :aria-label="t('自定义字号', 'Custom font size')"
@keydown.enter.prevent="applyFontSizeValue" />
<span>px</span>
<button type="button" :aria-label="t('应用自定义字号', 'Apply custom font size')" @pointerdown.prevent="applyFontSizeValue">{{ t('应用', 'Apply') }}</button>
</div>
<span class="toolbar-divider" />
<button type="button" :title="t('行内代码', 'Inline code')" :aria-label="t('行内代码', 'Inline code')" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button>
<button type="button" :title="t('代码块', 'Code block')" :aria-label="t('代码块', 'Code block')" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
<button v-if="markdownPreferences.math" type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button v-if="markdownPreferences.math" 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 v-if="markdownPreferences.callouts" :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 }">
<section v-if="metadata" class="note-metadata" :aria-label="t('笔记属性', 'Note properties')">
<span class="metadata-caption">{{ t('笔记属性', 'Note properties') }}</span>
<h1 v-if="metadata.title">{{ metadata.title }}</h1>
<div class="metadata-tags">
<span class="metadata-label">{{ t('标签', 'Tags') }}</span>
<span v-for="tag in metadata.tags" :key="tag" class="metadata-tag"><span>{{ tag }}</span><button type="button" :aria-label="`${t('移除标签', 'Remove tag')} ${tag}`" @click="setTags(metadata.tags.filter(item => item !== tag))">×</button></span>
<form @submit.prevent="addTags"><input v-model="tagDraft" :aria-label="t('添加标签', 'Add tag')" :placeholder="t('+ 添加标签', '+ Add tag')" /><button v-if="tagDraft.trim()" type="submit">{{ t('添加', 'Add') }}</button></form>
</div>
</section>
<div ref="editorRoot" />
</div>
</DiagramInteractions>
</template>
<style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
.hide-code-line-numbers :deep(.cm-lineNumbers) { display: none; }
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
.markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.section-actions { display: inline-flex; align-items: center; flex-shrink: 0; margin-inline-end: 8px; padding: 2px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-toolbar .section-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 28px; padding: 4px 8px; font: inherit; font-size: var(--font-size-xs); line-height: 1.25; white-space: nowrap; color: var(--color-text-secondary); }
.section-actions :deep(.app-icon) { transform: rotate(90deg); }
.markdown-toolbar .section-actions button:hover:not(:disabled) { background: var(--color-background-hover); color: var(--color-accent-primary); }
.markdown-toolbar .section-actions button:disabled { opacity: .45; cursor: default; }
.format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
.heading-glyph { font-weight: 800; }
.font-size-glyph { font-size: 18px; }
.list-glyph { grid-template-columns: 8px 14px; column-gap: 2px; font-weight: 700; }
.list-marker { font: 700 12px/1 var(--font-ui-sans); }
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
.font-size-select select { min-width: 62px; }
.font-size-input { display: inline-flex; align-items: center; height: 30px; margin-left: 2px; overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-secondary); background: var(--color-background-primary); }
.font-size-input:focus-within { border-color: var(--color-border-focus); box-shadow: 0 0 0 1px var(--color-border-focus); }
.font-size-input input { width: 42px; height: 100%; padding-left: 7px; border: 0; outline: 0; background: transparent; color: var(--color-text-primary); }
.font-size-input span { font-size: var(--font-size-xs); }
.font-size-input button { min-width: auto; min-height: 100%; margin-left: 4px; padding: 3px 7px; border-left: 1px solid var(--color-border-default); border-radius: 0; font-size: var(--font-size-xs); }
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
.milkdown-host.loading { visibility: hidden; }
.note-metadata { box-sizing: border-box; width: 90%; margin: 0 auto 20px; padding: 20px 24px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.metadata-caption { color: var(--color-text-secondary); font-size: var(--font-size-xs); }
.note-metadata h1 { margin: 10px 0 16px; font-size: 24px; color: var(--color-text-primary); overflow-wrap: anywhere; }
.metadata-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.metadata-label { margin-right: 4px; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
.metadata-tag { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border-radius: var(--radius-full); background: var(--color-accent-soft); color: var(--color-accent-primary); font-size: var(--font-size-sm); }
.metadata-tag > span { overflow-wrap: anywhere; min-width: 0; }
.metadata-tag button { color: inherit; padding: 0 3px; }
.metadata-tags form { display: flex; gap: 6px; }
.metadata-tags input { width: 110px; padding: 5px 8px; border: 1px dashed var(--color-border-default); border-radius: var(--radius-sm); background: transparent; color: var(--color-text-primary); }
.metadata-tags input:focus { outline: 2px solid var(--color-border-focus); }
.milkdown-host :deep(.editor-mermaid-preview) { padding: 20px; overflow: auto; background: var(--color-surface-primary); color: var(--color-text-primary); }
.milkdown-host :deep(.editor-mermaid-preview svg) { display: block; max-width: 100%; height: auto; margin: auto; }
.milkdown-host :deep(.editor-mermaid-preview.has-error) { color: var(--color-error); white-space: pre-wrap; }
.editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); }
.milkdown-host :deep(.milkdown) {
min-height: 100%;
background: transparent;
color: inherit;
--crepe-color-background: var(--color-background-primary);
--crepe-color-on-background: var(--color-text-primary);
--crepe-color-surface: var(--color-surface-primary);
--crepe-color-surface-low: var(--color-background-secondary);
--crepe-color-on-surface: var(--color-text-primary);
--crepe-color-on-surface-variant: var(--color-text-secondary);
--crepe-color-outline: var(--color-markdown-grid);
--crepe-color-primary: var(--color-accent-primary);
--crepe-color-secondary: var(--color-accent-soft);
--crepe-color-on-secondary: var(--color-text-primary);
--crepe-color-inverse: var(--color-text-primary);
--crepe-color-on-inverse: var(--color-background-primary);
--crepe-color-inline-code: var(--color-error);
--crepe-color-error: var(--color-error);
--crepe-color-hover: var(--color-background-hover);
--crepe-color-selected: var(--color-accent-soft);
--crepe-color-inline-area: var(--color-background-tertiary);
--crepe-font-default: var(--font-editor-sans);
--crepe-font-code: var(--font-editor-mono);
}
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.language-picker[popover]) { position: fixed !important; inset: auto; left: var(--picker-left) !important; top: var(--picker-top) !important; margin: 0; padding: 0; border: 0; overflow: visible; background: transparent; color: var(--color-text-primary); }
.milkdown-host :deep(.language-picker .language-list) { height: auto; max-height: var(--picker-list-height, 280px); }
.milkdown-host :deep(.language-picker .list-wrapper) { width: min(260px, calc(100vw - 24px)); border: 1px solid var(--color-border-default); background: var(--color-surface-elevated); box-shadow: var(--shadow-md); }
.milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
.milkdown-host :deep(.milkdown-code-block .cm-content) { caret-color: var(--color-code-text); font-family: var(--font-editor-mono); }
.milkdown-host :deep(.milkdown-code-block .language-button) { color: var(--color-code-muted); }
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
.milkdown-host :deep(.milkdown-table-block th),
.milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-markdown-grid); }
.milkdown-host :deep(.milkdown-table-block th) { background: var(--color-markdown-table-header); font-weight: 700; }
.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>