610 lines
15 KiB
Vue
610 lines
15 KiB
Vue
<template>
|
|
<div :class="{ 'fullscreen': isFullscreen }" class="markdown-editor-wrapper">
|
|
<!-- 编辑器工具栏 -->
|
|
<div class="editor-toolbar">
|
|
<div class="toolbar-left">
|
|
<a-space>
|
|
<a-tooltip title="标题">
|
|
<a-button size="small" @click="insertAtCursor('# ')">
|
|
<template #icon><span class="toolbar-icon">H</span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-divider type="vertical" />
|
|
<a-tooltip title="加粗">
|
|
<a-button size="small" @click="insertWrap('**', '**')">
|
|
<template #icon><span class="toolbar-icon bold">B</span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="斜体">
|
|
<a-button size="small" @click="insertWrap('*', '*')">
|
|
<template #icon><span class="toolbar-icon italic">I</span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="删除线">
|
|
<a-button size="small" @click="insertWrap('~~', '~~')">
|
|
<template #icon><span class="toolbar-icon">S</span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-divider type="vertical" />
|
|
<a-tooltip title="引用">
|
|
<a-button size="small" @click="insertAtCursor('> ')">
|
|
<template #icon><span class="toolbar-icon">></span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="代码块">
|
|
<a-button size="small" @click="insertCodeBlock">
|
|
<template #icon><span class="toolbar-icon"></></span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="行内代码">
|
|
<a-button size="small" @click="insertWrap('`', '`')">
|
|
<template #icon><span class="toolbar-icon">`</span></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-divider type="vertical" />
|
|
<a-tooltip title="链接">
|
|
<a-button size="small" @click="insertLink">
|
|
<template #icon><LinkOutlined /></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="图片">
|
|
<a-button size="small" @click="handleImageUpload">
|
|
<template #icon><PictureOutlined /></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-divider type="vertical" />
|
|
<a-tooltip title="有序列表">
|
|
<a-button size="small" @click="insertAtCursor('1. ')">
|
|
<template #icon><UnorderedListOutlined /></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-tooltip title="无序列表">
|
|
<a-button size="small" @click="insertAtCursor('- ')">
|
|
<template #icon><OrderedListOutlined /></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-divider type="vertical" />
|
|
<a-tooltip title="水平线">
|
|
<a-button size="small" @click="insertAtCursor('\n---\n')">
|
|
<template #icon><MinusOutlined /></template>
|
|
</a-button>
|
|
</a-tooltip>
|
|
</a-space>
|
|
</div>
|
|
<div class="toolbar-right">
|
|
<a-button size="small" @click="toggleFullscreen">
|
|
<template #icon><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined v-else /></template>
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑区域 -->
|
|
<div class="editor-body">
|
|
<div class="editor-pane">
|
|
<textarea
|
|
ref="textareaRef"
|
|
v-model="localValue"
|
|
:placeholder="placeholder"
|
|
:style="{ height: isFullscreen ? '100%' : 'auto' }"
|
|
class="markdown-textarea"
|
|
@input="handleInput"
|
|
@keydown="handleKeydown"
|
|
@scroll="syncScroll"
|
|
/>
|
|
</div>
|
|
<div v-if="showPreview" class="preview-pane">
|
|
<div class="preview-label">预览</div>
|
|
<div class="markdown-preview" v-html="renderedHtml"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 隐藏的文件上传 -->
|
|
<a-upload
|
|
ref="uploadRef"
|
|
:before-upload="beforeImageUpload"
|
|
:custom-request="handleCoverUpload"
|
|
:show-upload-list="false"
|
|
accept="image/*"
|
|
style="display: none"
|
|
/>
|
|
|
|
<!-- 插入链接弹窗 -->
|
|
<a-modal
|
|
v-model:open="showLinkModal"
|
|
:width="400"
|
|
title="插入链接"
|
|
@ok="insertLinkConfirm"
|
|
>
|
|
<a-form :model="linkForm" layout="vertical">
|
|
<a-form-item label="链接文本" required>
|
|
<a-input v-model:value="linkForm.text" placeholder="显示的文本" />
|
|
</a-form-item>
|
|
<a-form-item label="链接地址" required>
|
|
<a-input v-model:value="linkForm.url" placeholder="https://..." />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
|
|
<!-- 字数统计 -->
|
|
<div class="editor-footer">
|
|
<span class="word-count">{{ wordCount }} 字</span>
|
|
<span class="char-count">{{ charCount }} 字符</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import {
|
|
LinkOutlined,
|
|
PictureOutlined,
|
|
UnorderedListOutlined,
|
|
OrderedListOutlined,
|
|
MinusOutlined,
|
|
FullscreenOutlined,
|
|
FullscreenExitOutlined,
|
|
} from '@ant-design/icons-vue'
|
|
import { marked } from 'marked'
|
|
import hljs from 'highlight.js'
|
|
import 'highlight.js/styles/github.css'
|
|
import { message } from 'ant-design-vue'
|
|
import { uploadFile } from '@/api/system/file'
|
|
|
|
interface Props {
|
|
modelValue: string
|
|
placeholder?: string
|
|
showPreview?: boolean
|
|
minHeight?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
placeholder: '请输入 Markdown 内容...',
|
|
showPreview: true,
|
|
minHeight: '300px',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
}>()
|
|
|
|
// 配置 marked
|
|
marked.setOptions({
|
|
highlight: function(code: string, lang: string) {
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
return hljs.highlight(code, { language: lang }).value
|
|
}
|
|
return hljs.highlightAuto(code).value
|
|
},
|
|
breaks: true,
|
|
gfm: true,
|
|
})
|
|
|
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
|
const uploadRef = ref<any>(null)
|
|
const localValue = ref(props.modelValue)
|
|
const isFullscreen = ref(false)
|
|
const showLinkModal = ref(false)
|
|
const linkForm = reactive({
|
|
text: '',
|
|
url: '',
|
|
})
|
|
|
|
// 监听外部值变化
|
|
watch(() => props.modelValue, (newVal) => {
|
|
if (newVal !== localValue.value) {
|
|
localValue.value = newVal
|
|
}
|
|
})
|
|
|
|
// 计算属性
|
|
const renderedHtml = computed(() => {
|
|
if (!localValue.value) return '<p class="placeholder-hint">预览区域</p>'
|
|
try {
|
|
return marked.parse(localValue.value) as string
|
|
} catch (e) {
|
|
return '<p class="error-hint">渲染出错</p>'
|
|
}
|
|
})
|
|
|
|
const wordCount = computed(() => {
|
|
const text = localValue.value.trim()
|
|
if (!text) return 0
|
|
return text.split(/\s+/).filter(Boolean).length
|
|
})
|
|
|
|
const charCount = computed(() => localValue.value.length)
|
|
|
|
// 方法
|
|
function handleInput() {
|
|
emit('update:modelValue', localValue.value)
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
// Tab 键插入空格
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault()
|
|
insertAtCursor(' ')
|
|
}
|
|
// Ctrl/Cmd + B 加粗
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
|
e.preventDefault()
|
|
insertWrap('**', '**')
|
|
}
|
|
// Ctrl/Cmd + I 斜体
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
|
e.preventDefault()
|
|
insertWrap('*', '*')
|
|
}
|
|
// Ctrl/Cmd + K 链接
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
insertLink()
|
|
}
|
|
}
|
|
|
|
function insertAtCursor(text: string) {
|
|
const textarea = textareaRef.value
|
|
if (!textarea) return
|
|
|
|
const start = textarea.selectionStart
|
|
const end = textarea.selectionEnd
|
|
const before = localValue.value.substring(0, start)
|
|
const after = localValue.value.substring(end)
|
|
|
|
localValue.value = before + text + after
|
|
emit('update:modelValue', localValue.value)
|
|
|
|
nextTick(() => {
|
|
textarea.focus()
|
|
textarea.selectionStart = textarea.selectionEnd = start + text.length
|
|
})
|
|
}
|
|
|
|
function insertWrap(before: string, after: string) {
|
|
const textarea = textareaRef.value
|
|
if (!textarea) return
|
|
|
|
const start = textarea.selectionStart
|
|
const end = textarea.selectionEnd
|
|
const selectedText = localValue.value.substring(start, end) || '文本'
|
|
const text = before + selectedText + after
|
|
|
|
localValue.value = localValue.value.substring(0, start) + text + localValue.value.substring(end)
|
|
emit('update:modelValue', localValue.value)
|
|
|
|
nextTick(() => {
|
|
textarea.focus()
|
|
textarea.selectionStart = start + before.length
|
|
textarea.selectionEnd = start + before.length + selectedText.length
|
|
})
|
|
}
|
|
|
|
function insertCodeBlock() {
|
|
const textarea = textareaRef.value
|
|
if (!textarea) return
|
|
|
|
const start = textarea.selectionStart
|
|
const end = textarea.selectionEnd
|
|
const selectedText = localValue.value.substring(start, end) || '代码'
|
|
const text = '\n```javascript\n' + selectedText + '\n```\n'
|
|
|
|
localValue.value = localValue.value.substring(0, start) + text + localValue.value.substring(end)
|
|
emit('update:modelValue', localValue.value)
|
|
|
|
nextTick(() => {
|
|
textarea.focus()
|
|
textarea.selectionStart = start + 14 // 跳过 ```javascript\n
|
|
textarea.selectionEnd = start + 14 + selectedText.length
|
|
})
|
|
}
|
|
|
|
function insertLink() {
|
|
const textarea = textareaRef.value
|
|
if (textarea) {
|
|
const selectedText = localValue.value.substring(textarea.selectionStart, textarea.selectionEnd)
|
|
linkForm.text = selectedText || ''
|
|
}
|
|
showLinkModal.value = true
|
|
}
|
|
|
|
function insertLinkConfirm() {
|
|
if (!linkForm.text.trim()) {
|
|
message.warning('请输入链接文本')
|
|
return
|
|
}
|
|
if (!linkForm.url.trim()) {
|
|
message.warning('请输入链接地址')
|
|
return
|
|
}
|
|
insertAtCursor(`[${linkForm.text}](${linkForm.url})`)
|
|
showLinkModal.value = false
|
|
linkForm.text = ''
|
|
linkForm.url = ''
|
|
}
|
|
|
|
function handleImageUpload() {
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.accept = 'image/*'
|
|
input.onchange = async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (file) {
|
|
await uploadAndInsertImage(file)
|
|
}
|
|
}
|
|
input.click()
|
|
}
|
|
|
|
function beforeImageUpload(file: File) {
|
|
if (!file.type.startsWith('image/')) {
|
|
message.error('只能上传图片文件')
|
|
return false
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
message.error('图片大小不能超过 5MB')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function uploadAndInsertImage(file: File) {
|
|
try {
|
|
message.loading({ content: '上传图片中...', key: 'upload' })
|
|
const record = await uploadFile(file)
|
|
const url = (record?.url || record?.downloadUrl || '').trim()
|
|
if (!url) throw new Error('上传成功但未返回图片地址')
|
|
insertAtCursor(``)
|
|
message.success({ content: '图片上传成功', key: 'upload' })
|
|
} catch (e: any) {
|
|
message.error({ content: e?.message || '图片上传失败', key: 'upload' })
|
|
}
|
|
}
|
|
|
|
async function handleCoverUpload(option: any) {
|
|
await uploadAndInsertImage(option.file)
|
|
option.onSuccess?.()
|
|
}
|
|
|
|
function syncScroll(e: Event) {
|
|
const textarea = e.target as HTMLTextAreaElement
|
|
const preview = textarea.parentElement?.nextElementSibling?.querySelector('.markdown-preview') as HTMLElement
|
|
if (preview) {
|
|
const ratio = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight)
|
|
preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight)
|
|
}
|
|
}
|
|
|
|
function toggleFullscreen() {
|
|
isFullscreen.value = !isFullscreen.value
|
|
}
|
|
|
|
// 暴露方法
|
|
defineExpose({
|
|
focus: () => textareaRef.value?.focus(),
|
|
insertText: insertAtCursor,
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.markdown-editor-wrapper {
|
|
border: 1px solid #d9d9d9;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.markdown-editor-wrapper.fullscreen {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 1000;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: #fafafa;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.toolbar-left {
|
|
flex: 1;
|
|
}
|
|
|
|
.toolbar-right {
|
|
margin-left: 12px;
|
|
}
|
|
|
|
.toolbar-icon {
|
|
font-family: Georgia, serif;
|
|
font-weight: 700;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.toolbar-icon.bold {
|
|
font-weight: 900;
|
|
}
|
|
|
|
.toolbar-icon.italic {
|
|
font-style: italic;
|
|
}
|
|
|
|
.editor-body {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: v-bind(minHeight);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.editor-pane {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.markdown-textarea {
|
|
flex: 1;
|
|
padding: 16px;
|
|
border: none;
|
|
outline: none;
|
|
resize: none;
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
color: #24292e;
|
|
background: #fff;
|
|
}
|
|
|
|
.markdown-textarea::placeholder {
|
|
color: #aaa;
|
|
}
|
|
|
|
.preview-pane {
|
|
flex: 1;
|
|
border-left: 1px solid #f0f0f0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.preview-label {
|
|
padding: 8px 16px;
|
|
font-size: 12px;
|
|
color: #999;
|
|
background: #fafafa;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.markdown-preview {
|
|
flex: 1;
|
|
padding: 16px;
|
|
overflow-y: auto;
|
|
font-size: 14px;
|
|
line-height: 1.8;
|
|
color: #24292e;
|
|
}
|
|
|
|
.markdown-preview :deep(.placeholder-hint),
|
|
.markdown-preview :deep(.error-hint) {
|
|
color: #aaa;
|
|
font-style: italic;
|
|
}
|
|
|
|
.markdown-preview :deep(h1) {
|
|
font-size: 1.8em;
|
|
font-weight: 700;
|
|
margin: 0.5em 0;
|
|
padding-bottom: 0.3em;
|
|
border-bottom: 1px solid #eaecef;
|
|
}
|
|
|
|
.markdown-preview :deep(h2) {
|
|
font-size: 1.5em;
|
|
font-weight: 600;
|
|
margin: 0.8em 0 0.4em;
|
|
padding-bottom: 0.25em;
|
|
border-bottom: 1px solid #eaecef;
|
|
}
|
|
|
|
.markdown-preview :deep(h3) {
|
|
font-size: 1.25em;
|
|
font-weight: 600;
|
|
margin: 0.6em 0 0.3em;
|
|
}
|
|
|
|
.markdown-preview :deep(p) {
|
|
margin: 0.8em 0;
|
|
}
|
|
|
|
.markdown-preview :deep(code) {
|
|
padding: 0.2em 0.4em;
|
|
background: #f6f8fa;
|
|
border-radius: 4px;
|
|
font-family: 'Monaco', 'Menlo', monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.markdown-preview :deep(pre) {
|
|
padding: 16px;
|
|
background: #f6f8fa;
|
|
border-radius: 8px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.markdown-preview :deep(pre code) {
|
|
padding: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.markdown-preview :deep(blockquote) {
|
|
margin: 1em 0;
|
|
padding: 0.5em 1em;
|
|
border-left: 4px solid #dfe2e5;
|
|
color: #6a737d;
|
|
background: #f6f8fa;
|
|
}
|
|
|
|
.markdown-preview :deep(ul),
|
|
.markdown-preview :deep(ol) {
|
|
padding-left: 2em;
|
|
margin: 0.8em 0;
|
|
}
|
|
|
|
.markdown-preview :deep(li) {
|
|
margin: 0.3em 0;
|
|
}
|
|
|
|
.markdown-preview :deep(a) {
|
|
color: #0366d6;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.markdown-preview :deep(a:hover) {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.markdown-preview :deep(img) {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.markdown-preview :deep(hr) {
|
|
margin: 1.5em 0;
|
|
border: none;
|
|
border-top: 1px solid #dfe2e5;
|
|
}
|
|
|
|
.markdown-preview :deep(table) {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 1em 0;
|
|
}
|
|
|
|
.markdown-preview :deep(th),
|
|
.markdown-preview :deep(td) {
|
|
padding: 8px 12px;
|
|
border: 1px solid #dfe2e5;
|
|
}
|
|
|
|
.markdown-preview :deep(th) {
|
|
background: #f6f8fa;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.editor-footer {
|
|
display: flex;
|
|
gap: 16px;
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
color: #999;
|
|
background: #fafafa;
|
|
border-top: 1px solid #f0f0f0;
|
|
}
|
|
</style>
|