Fix/frontend review findings #4
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { CollectionTag, Edit, EditPen, List, Memo } from '@element-plus/icons-vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import {
|
||||
toggleEmphasisCommand,
|
||||
@@ -10,10 +9,10 @@ import {
|
||||
wrapInOrderedListCommand,
|
||||
} from '@milkdown/kit/preset/commonmark'
|
||||
import { callCommand } from '@milkdown/kit/utils'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { highlightCode } from '@/utils/markdown'
|
||||
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
@@ -24,13 +23,12 @@ const editorRoot = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
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) {
|
||||
const editor = crepe?.editor
|
||||
if (!editor) return
|
||||
const actions = {
|
||||
heading: callCommand(wrapInHeadingCommand.key, 2),
|
||||
bold: callCommand(toggleStrongCommand.key),
|
||||
italic: callCommand(toggleEmphasisCommand.key),
|
||||
'ordered-list': callCommand(wrapInOrderedListCommand.key),
|
||||
@@ -40,6 +38,21 @@ function runCommand(command: ToolbarCommand) {
|
||||
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 () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
@@ -63,6 +76,7 @@ onMounted(async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||
if (markdown === previousMarkdown || markdown === editorStore.content) return
|
||||
@@ -80,12 +94,26 @@ onBeforeUnmount(() => { void crepe?.destroy() })
|
||||
<template>
|
||||
<div class="visual-editor">
|
||||
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏">
|
||||
<button type="button" title="二级标题" @click="runCommand('heading')"><AppIcon :icon="CollectionTag" /><span>标题</span></button>
|
||||
<button type="button" title="加粗 (Ctrl+B)" @click="runCommand('bold')"><AppIcon :icon="Edit" /><span>加粗</span></button>
|
||||
<button type="button" title="斜体 (Ctrl+I)" @click="runCommand('italic')"><AppIcon :icon="EditPen" /><span>斜体</span></button>
|
||||
<label class="toolbar-select heading-select" title="设置标题级别">
|
||||
<span class="format-glyph heading-glyph">H</span>
|
||||
<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" />
|
||||
<button type="button" title="有序列表" @click="runCommand('ordered-list')"><AppIcon :icon="Memo" /><span>有序列表</span></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('ordered-list')">1.</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 v-if="loading" class="editor-loading">正在加载编辑器…</div>
|
||||
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
|
||||
@@ -95,10 +123,16 @@ onBeforeUnmount(() => { void crepe?.destroy() })
|
||||
<style scoped>
|
||||
.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 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: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 span { font-size: var(--font-size-xs); }
|
||||
.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; }
|
||||
.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); }
|
||||
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
|
||||
.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-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(.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),
|
||||
@@ -140,5 +179,5 @@ onBeforeUnmount(() => { void crepe?.destroy() })
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
: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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user