UnisMindMap/web_ui/src/components/MarkdownRenderer.vue

685 lines
15 KiB
Vue

<template>
<div class="markdown-renderer">
<template v-if="content">
<div class="content-toolbar render-actions">
<div class="render-actions-left">
<el-segmented
v-if="mode === 'markdown'"
v-model="activeFlavor"
:options="flavorOptions"
size="small"
/>
<el-segmented
v-else-if="mode === 'richtext'"
v-model="activeRichtextFormat"
:options="richtextOptions"
size="small"
/>
</div>
<div class="render-actions-right">
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
<template #icon>
<el-icon><Download /></el-icon>
</template>
下载
</el-button>
</div>
</div>
<div class="render-content">
<div v-if="mode === 'markdown'" class="render-shell">
<div ref="markdownRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
</div>
<div v-else-if="mode === 'html'" class="render-shell">
<div ref="htmlRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
</div>
<div v-else-if="mode === 'pdf'" class="render-shell">
<div class="pdf-stage">
<div ref="pdfRef" class="pdf-preview-pages">
<div
v-for="(page, index) in pdfPages"
:key="`pdf-page-${index}`"
class="pdf-page"
>
<div class="markdown-content pdf-content" v-html="page"></div>
</div>
</div>
</div>
<div
ref="pdfMeasureRef"
class="pdf-measure markdown-content pdf-content"
v-html="renderedContent"
></div>
</div>
<div v-else class="render-shell">
<div ref="richtextRef" class="markdown-content rendered-html richtext-content" v-html="renderedContent"></div>
</div>
</div>
</template>
<div v-else class="empty-state">
<el-empty :description="$t('results.noResults')" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import type { PropType } from 'vue'
import { Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { loadHtml2Pdf } from '@/utils/wordToPdf'
type RenderMode = 'markdown' | 'html' | 'pdf' | 'richtext'
type FlavorMode = 'commonmark' | 'gfm'
type RichtextFormat = 'word' | 'rtf'
const props = defineProps({
content: {
type: String as PropType<string>,
default: ''
},
mode: {
type: String as PropType<RenderMode>,
default: 'markdown'
},
flavor: {
type: String as PropType<FlavorMode>,
default: 'gfm'
}
})
const activeFlavor = ref<FlavorMode>(props.flavor)
const activeRichtextFormat = ref<RichtextFormat>('word')
const markdownRef = ref<HTMLElement | null>(null)
const htmlRef = ref<HTMLElement | null>(null)
const pdfRef = ref<HTMLElement | null>(null)
const pdfMeasureRef = ref<HTMLElement | null>(null)
const richtextRef = ref<HTMLElement | null>(null)
const pdfPages = ref<string[]>([])
watch(
() => props.flavor,
(value) => {
activeFlavor.value = value
}
)
const flavorOptions = [
{ label: 'CommonMark', value: 'commonmark' },
{ label: 'GFM', value: 'gfm' }
]
const richtextOptions = [
{ label: 'Word', value: 'word' },
{ label: 'RTF', value: 'rtf' }
]
function applyMathSupport(md: MarkdownIt) {
md.use(function (md) {
md.inline.ruler.after('escape', 'math_inline', function (state, silent) {
const start = state.pos
if (state.src.charCodeAt(start) !== 0x24) return false
if (state.src.charCodeAt(start + 1) !== 0x24) return false
let pos = start + 2
let found = false
while (pos < state.posMax) {
if (state.src.charCodeAt(pos) === 0x24 && state.src.charCodeAt(pos + 1) === 0x24) {
found = true
break
}
pos++
}
if (!found) return false
if (!silent) {
const token = state.push('math_inline', 'span', 0)
token.content = state.src.slice(start + 2, pos)
token.markup = '$$'
}
state.pos = pos + 2
return true
})
md.inline.ruler.after('escape', 'math_inline_single', function (state, silent) {
const start = state.pos
if (state.src.charCodeAt(start) !== 0x24) return false
if (state.src.charCodeAt(start + 1) === 0x24) return false
let pos = start + 1
let found = false
while (pos < state.posMax) {
if (state.src.charCodeAt(pos) === 0x24) {
found = true
break
}
pos++
}
if (!found) return false
if (!silent) {
const token = state.push('math_inline_single', 'span', 0)
token.content = state.src.slice(start + 1, pos)
token.markup = '$'
}
state.pos = pos + 1
return true
})
})
md.renderer.rules.math_inline = function(tokens, idx) {
return `<span class="math-inline">$$${tokens[idx].content}$$</span>`
}
md.renderer.rules.math_inline_single = function(tokens, idx) {
return `<span class="math-inline">$${tokens[idx].content}$</span>`
}
}
function createMarkdownRenderer(flavor: FlavorMode) {
const md = flavor === 'commonmark'
? new MarkdownIt('commonmark', {
html: true,
linkify: true,
typographer: true,
breaks: false
})
: new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true
})
applyMathSupport(md)
return md
}
const renderedContent = computed(() => {
if (!props.content) return ''
const flavor = props.mode === 'markdown' ? activeFlavor.value : 'gfm'
const renderer = createMarkdownRenderer(flavor)
return renderer.render(props.content)
})
async function recalculatePdfPages() {
if (props.mode !== 'pdf') {
pdfPages.value = []
return
}
await nextTick()
const measureEl = pdfMeasureRef.value
if (!measureEl) {
pdfPages.value = renderedContent.value ? [renderedContent.value] : []
return
}
const children = Array.from(measureEl.children) as HTMLElement[]
if (children.length === 0) {
pdfPages.value = renderedContent.value ? [renderedContent.value] : []
return
}
const maxPageHeight = 980
const pages: string[] = []
let currentPageHtml = ''
let currentHeight = 0
children.forEach((child) => {
const childHeight = child.offsetHeight || child.scrollHeight || 0
const childHtml = child.outerHTML
if (currentPageHtml && currentHeight + childHeight > maxPageHeight) {
pages.push(currentPageHtml)
currentPageHtml = childHtml
currentHeight = childHeight
return
}
currentPageHtml += childHtml
currentHeight += childHeight
})
if (currentPageHtml) {
pages.push(currentPageHtml)
}
pdfPages.value = pages.length > 0 ? pages : [renderedContent.value]
}
watch(
() => [props.mode, renderedContent.value],
() => {
void recalculatePdfPages()
},
{ immediate: true }
)
onMounted(() => {
void recalculatePdfPages()
})
const baseFileName = computed(() => `markdown_render_${new Date().getTime()}`)
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function createFullHtmlDocument(innerHtml: string, title: string) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<style>
body { font-family: "Microsoft YaHei", Arial, sans-serif; color: #1f2937; padding: 32px; line-height: 1.75; }
h1, h2, h3, h4, h5, h6 { color: #111827; }
pre { background: #f5f7fa; padding: 16px; border-radius: 8px; overflow: auto; }
code { background: #f5f7fa; padding: 2px 4px; border-radius: 4px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #dcdfe6; padding: 8px 12px; text-align: left; }
blockquote { border-left: 4px solid #dcdfe6; margin: 1em 0; padding-left: 1em; color: #606266; }
img { max-width: 100%; }
</style>
</head>
<body>${innerHtml}</body>
</html>`
}
function htmlToPlainText(html: string) {
const temp = document.createElement('div')
temp.innerHTML = html
return temp.innerText || temp.textContent || ''
}
function escapeRtf(text: string) {
return text
.replace(/\\/g, '\\\\')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\n/g, '\\par\n')
}
async function exportHtml(innerHtml: string, fileName: string) {
const html = createFullHtmlDocument(innerHtml, fileName)
downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), fileName)
}
async function exportMarkdown(markdown: string, fileName: string) {
downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), fileName)
}
async function exportPdf(element: HTMLElement | null, fileName: string) {
if (!element) {
ElMessage.error('当前没有可导出的 PDF 内容')
return
}
await nextTick()
await loadHtml2Pdf()
const pdfBlob = await window.html2pdf()
.set({
margin: 10,
filename: fileName,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
})
.from(element)
.outputPdf('blob')
downloadBlob(pdfBlob, fileName)
}
async function exportDoc(innerHtml: string, fileName: string) {
const docHtml = createFullHtmlDocument(innerHtml, fileName)
downloadBlob(new Blob(['\ufeff', docHtml], { type: 'application/msword' }), fileName)
}
async function exportRtf(innerHtml: string, fileName: string) {
const plainText = htmlToPlainText(innerHtml)
const rtf = `{\\rtf1\\ansi\\deff0 {\\fonttbl{\\f0 Calibri;}}\n\\fs24 ${escapeRtf(plainText)}}`
downloadBlob(new Blob([rtf], { type: 'application/rtf' }), fileName)
}
async function handleDownload() {
if (props.mode === 'markdown') {
const suffix = activeFlavor.value === 'commonmark' ? '_commonmark' : '_gfm'
await exportMarkdown(props.content, `${baseFileName.value}${suffix}.md`)
return
}
if (props.mode === 'html') {
await exportHtml(renderedContent.value, `${baseFileName.value}.html`)
return
}
if (props.mode === 'pdf') {
await exportPdf(pdfRef.value, `${baseFileName.value}.pdf`)
return
}
if (activeRichtextFormat.value === 'word') {
await exportDoc(renderedContent.value, `${baseFileName.value}.doc`)
return
}
await exportRtf(renderedContent.value, `${baseFileName.value}.rtf`)
}
</script>
<style scoped>
.markdown-renderer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #FFFFFF;
border-radius: 4px;
box-shadow: none;
overflow: hidden;
min-height: 0;
}
.content-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.content-toolbar-title {
font-size: 14px;
font-weight: 600;
color: #343A40;
white-space: nowrap;
}
.content-toolbar-meta {
font-size: 12px;
color: #86909C;
white-space: nowrap;
}
.render-actions {
padding: 0 0 12px;
background-color: #FFFFFF;
}
.render-actions-left {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
flex: 1;
min-width: 0;
}
.render-actions-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.action-button {
border-radius: 6px;
transition: all 0.3s ease;
height: 32px;
padding: 0 12px;
}
.action-button.primary {
background-color: #165dff;
border-color: #165dff;
color: #ffffff;
}
.action-button.primary:hover {
background-color: #0e42d2;
border-color: #0e42d2;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(22, 93, 255, 0.3);
}
.render-shell {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.render-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0;
}
.render-header {
padding: 14px 16px;
border-radius: 10px;
background: linear-gradient(135deg, #f7faff 0%, #eef4ff 100%);
border: 1px solid #d7e4ff;
}
.render-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.render-desc {
margin-top: 4px;
font-size: 13px;
line-height: 1.6;
color: #5b6472;
}
.markdown-content {
padding: 20px;
background: white;
border-radius: 4px;
min-height: 400px;
}
.rendered-html {
border: 1px solid #e9ecef;
}
.pdf-stage {
padding: 20px;
border: 1px solid #E9ECEF;
border-radius: 4px;
background: #F8F9FA;
}
.pdf-preview-pages {
display: flex;
flex-direction: column;
gap: 20px;
}
.pdf-page {
width: min(100%, 840px);
min-height: 1160px;
margin: 0 auto;
background: white;
border: 1px solid #d9dee8;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12);
padding: 28px 36px 36px;
}
.pdf-content {
padding: 0;
min-height: auto;
}
.pdf-measure {
position: absolute;
left: -99999px;
top: 0;
width: 768px;
visibility: hidden;
pointer-events: none;
z-index: -1;
}
.richtext-stage {
padding: 12px;
border: 1px solid #E9ECEF;
border-radius: 4px;
background: #F8F9FA;
}
.richtext-document {
min-height: 100%;
background: white;
border: 1px solid #dde3ea;
border-radius: 4px;
overflow: hidden;
box-shadow: none;
}
.richtext-content {
padding: 32px 40px 48px;
}
.markdown-content :deep(h1) {
font-size: 2em;
margin: 0.67em 0;
font-weight: bold;
}
.markdown-content :deep(h2) {
font-size: 1.5em;
margin: 0.83em 0;
font-weight: bold;
}
.markdown-content :deep(h3) {
font-size: 1.17em;
margin: 1em 0;
font-weight: bold;
}
.markdown-content :deep(p) {
margin: 1em 0;
line-height: 1.75;
}
.markdown-content :deep(code) {
background-color: #f5f7fa;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.markdown-content :deep(pre) {
background-color: #f5f7fa;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.markdown-content :deep(pre code) {
background: none;
padding: 0;
}
.markdown-content :deep(blockquote) {
margin: 1em 0;
padding: 0 1em;
border-left: 4px solid #dcdfe6;
color: #606266;
}
.markdown-content :deep(ul), .markdown-content :deep(ol) {
margin: 1em 0;
padding-left: 2em;
}
.markdown-content :deep(li) {
margin: 0.5em 0;
}
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content :deep(th), .markdown-content :deep(td) {
border: 1px solid #dcdfe6;
padding: 8px 12px;
text-align: left;
}
.markdown-content :deep(th) {
background-color: #f5f7fa;
font-weight: bold;
}
.markdown-content :deep(img) {
max-width: 100%;
height: auto;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
}
.math-inline {
font-family: 'Cambria Math', 'STIX Two Math', serif;
font-size: 1.1em;
}
@media (max-width: 768px) {
.render-actions {
flex-direction: column;
align-items: stretch;
}
.render-actions-left {
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.render-actions-right {
justify-content: flex-end;
}
.pdf-page {
padding: 24px 20px 32px;
min-height: auto;
}
.richtext-content {
padding: 24px 18px 32px;
}
.action-button {
flex-shrink: 0;
}
}
</style>