feat(frontend): 完善Markdown选区格式工具栏

This commit is contained in:
2026-08-30 10:07:31 +08:00
parent 9d223be5ca
commit 8ee3adae6a
2 changed files with 117 additions and 14 deletions
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { CollectionTag, Edit, EditPen, List, Memo } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe' import { Crepe } from '@milkdown/crepe'
import { import {
toggleEmphasisCommand, toggleEmphasisCommand,
@@ -10,10 +9,10 @@ import {
wrapInOrderedListCommand, wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark' } from '@milkdown/kit/preset/commonmark'
import { callCommand } from '@milkdown/kit/utils' import { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { highlightCode } from '@/utils/markdown' import { highlightCode } from '@/utils/markdown'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
@@ -24,13 +23,12 @@ const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true) const loading = ref(true)
let crepe: Crepe | null = null let crepe: Crepe | null = null
type ToolbarCommand = 'heading' | 'bold' | 'italic' | 'ordered-list' | 'bullet-list' type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list'
function runCommand(command: ToolbarCommand) { function runCommand(command: ToolbarCommand) {
const editor = crepe?.editor const editor = crepe?.editor
if (!editor) return if (!editor) return
const actions = { const actions = {
heading: callCommand(wrapInHeadingCommand.key, 2),
bold: callCommand(toggleStrongCommand.key), bold: callCommand(toggleStrongCommand.key),
italic: callCommand(toggleEmphasisCommand.key), italic: callCommand(toggleEmphasisCommand.key),
'ordered-list': callCommand(wrapInOrderedListCommand.key), 'ordered-list': callCommand(wrapInOrderedListCommand.key),
@@ -40,6 +38,21 @@ function runCommand(command: ToolbarCommand) {
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus() editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
} }
function applyHeading(event: Event) {
const level = Number((event.target as HTMLSelectElement).value)
if (!level || !crepe) return
crepe.editor.action(callCommand(wrapInHeadingCommand.key, level))
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
applyMarkdownFontSize(crepe.editor, size)
;(event.target as HTMLSelectElement).value = ''
}
onMounted(async () => { onMounted(async () => {
crepe = new Crepe({ crepe = new Crepe({
root: editorRoot.value, root: editorRoot.value,
@@ -63,6 +76,7 @@ onMounted(async () => {
}, },
}, },
}) })
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
if (markdown === previousMarkdown || markdown === editorStore.content) return if (markdown === previousMarkdown || markdown === editorStore.content) return
@@ -80,12 +94,26 @@ onBeforeUnmount(() => { void crepe?.destroy() })
<template> <template>
<div class="visual-editor"> <div class="visual-editor">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏"> <div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏">
<button type="button" title="二级标题" @click="runCommand('heading')"><AppIcon :icon="CollectionTag" /><span>标题</span></button> <label class="toolbar-select heading-select" title="设置标题级别">
<button type="button" title="加粗 (Ctrl+B)" @click="runCommand('bold')"><AppIcon :icon="Edit" /><span>加粗</span></button> <span class="format-glyph heading-glyph">H</span>
<button type="button" title="斜体 (Ctrl+I)" @click="runCommand('italic')"><AppIcon :icon="EditPen" /><span>斜体</span></button> <select aria-label="标题级别" @change="applyHeading">
<option value="" selected>标题</option>
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
</select>
</label>
<button type="button" title="加粗 (Ctrl+B)" aria-label="加粗" @mousedown.prevent @click="runCommand('bold')"><strong class="format-glyph">B</strong></button>
<button type="button" title="斜体 (Ctrl+I)" aria-label="斜体" @mousedown.prevent @click="runCommand('italic')"><em class="format-glyph">I</em></button>
<span class="toolbar-divider" /> <span class="toolbar-divider" />
<button type="button" title="有序列表" @click="runCommand('ordered-list')"><AppIcon :icon="Memo" /><span>有序列表</span></button> <button type="button" class="list-glyph" title="有序列表" aria-label="有序列表" @mousedown.prevent @click="runCommand('ordered-list')">1.</button>
<button type="button" title="无序列表" @click="runCommand('bullet-list')"><AppIcon :icon="List" /><span>无序列表</span></button> <button type="button" class="list-glyph" title="无序列表" aria-label="无序列表" @mousedown.prevent @click="runCommand('bullet-list')"></button>
<span class="toolbar-divider" />
<label class="toolbar-select font-size-select" title="修改选中文字的字号">
<span class="format-glyph font-size-glyph">A</span>
<select aria-label="文字字号" @change="applyFontSize">
<option value="" selected>字号</option>
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
</select>
</label>
</div> </div>
<div v-if="loading" class="editor-loading">正在加载编辑器</div> <div v-if="loading" class="editor-loading">正在加载编辑器</div>
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" /> <div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
@@ -95,10 +123,16 @@ onBeforeUnmount(() => { void crepe?.destroy() })
<style scoped> <style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); } .visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
.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 { 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-flex; align-items: center; gap: 5px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-secondary); } .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 { background: var(--color-background-hover); 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 { outline: 2px solid var(--color-border-focus); outline-offset: 1px; } .markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.markdown-toolbar button span { font-size: var(--font-size-xs); } .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 { font-size: 17px; font-weight: 700; }
.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; }
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); } .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 { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
.milkdown-host.loading { visibility: hidden; } .milkdown-host.loading { visibility: hidden; }
@@ -129,6 +163,11 @@ onBeforeUnmount(() => { void crepe?.destroy() })
} }
.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) { 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-selectednode) { outline-color: var(--color-accent-primary); }
.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-toolbar) { border: 1px solid var(--color-border-default); background: var(--color-surface-elevated); box-shadow: var(--shadow-md); }
.milkdown-host :deep(.milkdown-toolbar .toolbar-item svg), .milkdown-host :deep(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary); fill: var(--color-text-primary); stroke: currentColor; opacity: 1; }
.milkdown-host :deep(.milkdown-toolbar .toolbar-item:hover svg) { color: var(--color-accent-primary); fill: var(--color-accent-primary); }
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); } .milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
.milkdown-host :deep(.shiki) { box-sizing: border-box; width: 100%; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); } .milkdown-host :deep(.shiki) { box-sizing: border-box; width: 100%; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); }
:global([data-theme='dark']) .milkdown-host :deep(.shiki), :global([data-theme='dark']) .milkdown-host :deep(.shiki),
@@ -140,5 +179,5 @@ onBeforeUnmount(() => { void crepe?.destroy() })
text-decoration: var(--shiki-dark-text-decoration) !important; text-decoration: var(--shiki-dark-text-decoration) !important;
} }
:global([data-theme='dark']) .milkdown-host :deep(.milkdown) { color-scheme: dark; } :global([data-theme='dark']) .milkdown-host :deep(.milkdown) { color-scheme: dark; }
@media (max-width: 680px) { .markdown-toolbar button span { display: none; } } @media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
</style> </style>
@@ -0,0 +1,64 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
const openingTag = /^<span style="font-size:\s*(\d+(?:\.\d+)?)px">$/i
const closingTag = /^<\/span>$/i
export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = []
const stack: Array<{ from: number; size: string }> = []
state.doc.descendants((node, position) => {
if (node.type.name !== 'html') return
const value = String(node.attrs.value ?? '')
const match = value.match(openingTag)
if (match) {
stack.push({ from: position + node.nodeSize, size: match[1] })
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
return
}
if (closingTag.test(value)) {
const opening = stack.pop()
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
if (opening && opening.from < position) {
decorations.push(Decoration.inline(opening.from, position, {
style: `font-size: ${opening.size}px`,
'data-font-size': opening.size,
}))
}
}
})
return DecorationSet.create(state.doc, decorations)
},
},
}))
export function applyMarkdownFontSize(editor: Editor, size: number): boolean {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to, empty } = view.state.selection
if (empty) return false
const htmlNode = view.state.schema.nodes.html
if (!htmlNode) return false
const opening = htmlNode.create({ value: `<span style="font-size: ${size}px">` })
const closing = htmlNode.create({ value: '</span>' })
const transaction = view.state.tr
.insert(to, closing)
.insert(from, opening)
transaction.setSelection(TextSelection.create(transaction.doc, from + 1, to + 1))
view.dispatch(transaction)
view.focus()
return true
})
}