685 lines
15 KiB
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>
|