feat: add docx and html2pdf.js for document export functionality

This commit is contained in:
2026-03-10 22:21:11 +08:00
parent 637456ee34
commit 2ad57887cd
7 changed files with 1381 additions and 90 deletions
+566 -19
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
@@ -48,20 +48,27 @@
</button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
<button
type="button"
class="action-btn"
:aria-label="t('exportMd')"
:title="t('exportMd')"
@click="exportMarkdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="btn-tooltip">{{ t('exportMd') }}</span>
</button>
<div class="export-btn-wrapper">
<button
type="button"
class="action-btn"
:aria-label="t('exportMd')"
:title="t('exportMd')"
@click="toggleExportDropdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="btn-tooltip">{{ t('exportMd') }}</span>
</button>
<div v-if="showExportDropdown" class="export-dropdown">
<button type="button" @click="exportMarkdown">{{ t('exportMd') }}</button>
<button type="button" @click="exportToDocx">{{ t('exportDocx') }}</button>
<button type="button" @click="exportToPdf">{{ t('exportPdf') }}</button>
</div>
</div>
<div class="image-btn-wrapper">
<button
@@ -128,6 +135,15 @@
</div>
</div>
</div>
<div v-if="showMermaidPreview" class="mermaid-preview-overlay" @click.self="closeMermaidPreview">
<div class="mermaid-preview-dialog" role="dialog" aria-modal="true" aria-label="Mermaid Preview">
<button type="button" class="mermaid-preview-close" @click="closeMermaidPreview" aria-label="Close preview"></button>
<div class="mermaid-preview-scroll">
<img :src="mermaidPreviewSrc" alt="Mermaid diagram preview">
</div>
</div>
</div>
</div>
</template>
@@ -135,15 +151,19 @@
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
import { mermaidRenderPreview, codeBlockConfig, refreshMermaidPreviews } from '../plugins/mermaidPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import MarkdownIt from 'markdown-it'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import html2pdf from 'html2pdf.js'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
@@ -156,7 +176,10 @@ const cameraInputRef = ref(null)
const aiEnabled = ref(true)
const contentSize = ref(0)
const showImageDropdown = ref(false)
const showExportDropdown = ref(false)
const showUrlDialog = ref(false)
const showMermaidPreview = ref(false)
const mermaidPreviewSrc = ref('')
const imageUrl = ref('')
const canUndo = ref(false)
const canRedo = ref(false)
@@ -178,6 +201,8 @@ const aiButtonLabel = computed(() => {
let crepe = null
let markdownSyncTimer = null
let rootResizeObserver = null
let themeObserver = null
let mermaidResizeTimer = null
const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
@@ -392,6 +417,170 @@ const prepareImageFile = async (file) => {
return objectUrl
}
const closeMermaidPreview = () => {
showMermaidPreview.value = false
mermaidPreviewSrc.value = ''
}
const openMermaidPreview = (url) => {
if (!url) return
mermaidPreviewSrc.value = url
showMermaidPreview.value = true
}
const makeMermaidFilename = () => {
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
return `mermaid-${datePart}-${timePart}.png`
}
const normalizeMermaidFilename = (filename = '') => {
if (!filename) return makeMermaidFilename()
if (/\.png$/i.test(filename)) return filename
if (/\.[^./\\]+$/.test(filename)) return filename.replace(/\.[^./\\]+$/, '.png')
return `${filename}.png`
}
const parseSvgSize = (svg) => {
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
if (viewBox) {
const width = Number(viewBox[1])
const height = Number(viewBox[2])
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { width, height }
}
}
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
const width = widthAttr ? Number(widthAttr[1]) : 960
const height = heightAttr ? Number(heightAttr[1]) : 540
return {
width: Number.isFinite(width) && width > 0 ? width : 960,
height: Number.isFinite(height) && height > 0 ? height : 540,
}
}
const getRasterDpr = (width, height) => {
const rawDpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
const baseDpr = Math.min(3, Math.max(1, rawDpr))
const maxCanvasEdge = 4096
const edgeLimitedDpr = maxCanvasEdge / Math.max(width, height, 1)
return Math.max(1, Math.min(baseDpr, edgeLimitedDpr))
}
const decodeSvgDataUrl = (url) => {
const commaIndex = url.indexOf(',')
if (commaIndex === -1) throw new Error('Invalid SVG data URL')
const header = url.slice(0, commaIndex)
const payload = url.slice(commaIndex + 1)
if (/;base64/i.test(header)) {
const binary = atob(payload)
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
return decodeURIComponent(payload)
}
const svgTextToPngDataUrl = async (svgText) => {
const fallback = parseSvgSize(svgText)
let width = fallback.width
let height = fallback.height
const maxEdge = 2400
const scale = Math.min(1, maxEdge / Math.max(width, height))
width = Math.max(1, Math.round(width * scale))
height = Math.max(1, Math.round(height * scale))
const image = new Image()
image.decoding = 'async'
image.crossOrigin = 'anonymous'
await new Promise((resolve, reject) => {
image.onload = () => resolve()
image.onerror = () => reject(new Error('Failed to load SVG image'))
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`
})
const dpr = getRasterDpr(width, height)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width * dpr))
canvas.height = Math.max(1, Math.round(height * dpr))
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas context unavailable')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.clearRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
return canvas.toDataURL('image/png')
}
const ensurePngDownloadUrl = async (url) => {
if (!url) return ''
if (/^data:image\/png/i.test(url)) return url
if (/^data:image\/svg\+xml/i.test(url)) {
const svgText = decodeSvgDataUrl(url)
return await svgTextToPngDataUrl(svgText)
}
return url
}
const downloadMermaidImage = async (url, filename = '') => {
if (!url) return
let downloadUrl = url
try {
downloadUrl = await ensurePngDownloadUrl(url)
} catch (error) {
alert('PNG export failed for this diagram. Please remove external image resources and try again.')
return
}
const a = document.createElement('a')
a.href = downloadUrl
a.download = normalizeMermaidFilename(filename)
document.body.appendChild(a)
a.click()
a.remove()
}
const handleMermaidAction = async (event) => {
const target = event.target instanceof Element ? event.target.closest('[data-mermaid-action]') : null
if (!(target instanceof HTMLElement)) return
const action = target.getAttribute('data-mermaid-action')
if (!action) return
const block = target.closest('.mermaid-block')
const url = target.getAttribute('data-mermaid-url') || block?.getAttribute('data-mermaid-url') || ''
if (!url) return
event.preventDefault()
event.stopPropagation()
if (action === 'zoom') {
openMermaidPreview(url)
return
}
if (action === 'download') {
const filename = target.getAttribute('data-mermaid-filename') || ''
await downloadMermaidImage(url, filename)
}
}
const handleMermaidViewportResize = () => {
if (mermaidResizeTimer) {
clearTimeout(mermaidResizeTimer)
mermaidResizeTimer = null
}
mermaidResizeTimer = setTimeout(() => {
mermaidResizeTimer = null
refreshMermaidPreviews()
}, 140)
}
onMounted(async () => {
if (!root.value) throw new Error('root.value is null')
updateEditorTailSpace()
@@ -401,10 +590,12 @@ onMounted(async () => {
})
rootResizeObserver.observe(root.value)
}
root.value.addEventListener('click', handleMermaidAction, true)
window.addEventListener('resize', handleMermaidViewportResize)
crepe = new Crepe({
root: root.value,
defaultValue: '# 娆㈣繋鏉ュ埌LLM-IN-TEXT\n\n涓€涓嵆鏃禠LM绯荤粺\n\n鍦ㄤ笅闈㈠紑濮嬩綘鐨勫垱浣?..',
defaultValue: '# 欢迎来到LLM-IN-TEXT\n\n一个即时LLM系统\n\n在下开始你的创作...',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
@@ -464,6 +655,7 @@ onMounted(async () => {
await crepe.create()
refreshMermaidPreviews()
crepe.on((listener) => {
listener.updated((ctx, doc) => {
@@ -481,6 +673,20 @@ onMounted(async () => {
refreshSizeAndLimit(ctx)
updateHistoryState(view)
})
if (typeof MutationObserver !== 'undefined') {
themeObserver = new MutationObserver((mutations) => {
const changed = mutations.some((mutation) => mutation.type === 'attributes' && mutation.attributeName === 'data-theme')
if (changed) {
refreshMermaidPreviews()
}
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
}
scheduleMarkdownSync()
})
@@ -508,6 +714,233 @@ const exportMarkdown = async () => {
URL.revokeObjectURL(url)
}
// 棰勫鐞?LaTeX 鍏紡
const preprocessLatex = (text) => {
// 澶勭悊 $$...$$ 鍧楃骇鍏紡
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (match, content) => {
try {
const html = katex.renderToString(content.trim(), {
displayMode: true,
throwOnError: false
})
return `<div class="math-block">${html}</div>`
} catch (e) {
return `<div class="math-error">$$${content}$$</div>`
}
})
// 澶勭悊 $...$ 琛屽唴鍏紡
text = text.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (match, content) => {
try {
const html = katex.renderToString(content.trim(), {
displayMode: false,
throwOnError: false
})
return `<span class="math-inline">${html}</span>`
} catch (e) {
return `<span class="math-error">${match}</span>`
}
})
return text
}
// 灏?markdown 杞崲涓?HTML 骞跺鐞?mermaid
const decodeHtmlEntities = (input) => {
if (!input) return ''
const textarea = document.createElement('textarea')
textarea.innerHTML = input
return textarea.value
}
const collectRenderedMermaidImages = () => {
const imageMap = new Map()
const blocks = document.querySelectorAll('.mermaid-block[data-mermaid-code][data-mermaid-url]')
for (const block of blocks) {
const encodedCode = block.getAttribute('data-mermaid-code') || ''
const imageUrl = block.getAttribute('data-mermaid-url') || ''
if (!encodedCode || !imageUrl) continue
try {
const code = normalizeMermaidCodeForExport(decodeURIComponent(encodedCode))
if (code) imageMap.set(code, imageUrl)
} catch (error) {
continue
}
}
return imageMap
}
const normalizeMermaidCodeForExport = (code) => {
return (code || '').replace(/\r\n/g, '\n').trim()
}
const markdownToHtml = async (markdown) => {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})
// 棰勫鐞?LaTeX
let html = preprocessLatex(markdown)
// 娓叉煋 markdown
html = md.render(html)
// Mermaid code block -> rendered image
const mermaidImageMap = collectRenderedMermaidImages()
const template = document.createElement('template')
template.innerHTML = html
const mermaidNodes = template.content.querySelectorAll('pre > code.language-mermaid')
for (const node of mermaidNodes) {
const pre = node.parentElement
if (!(pre instanceof HTMLElement)) continue
const normalizedCode = normalizeMermaidCodeForExport(decodeHtmlEntities(node.textContent || ''))
const imgUrl = mermaidImageMap.get(normalizedCode)
if (!imgUrl) {
pre.remove()
continue
}
const wrapper = document.createElement('div')
wrapper.className = 'mermaid-export'
const image = document.createElement('img')
image.src = imgUrl
image.alt = 'Mermaid diagram'
wrapper.appendChild(image)
pre.replaceWith(wrapper)
}
html = template.innerHTML
html = html.replace(/<pre class="language-(\w+)"><code>([\s\S]*?)<\/code><\/pre>/g,
'<pre class="code-simple"><code>$2</code></pre>')
// Process remote images
html = html.replace(/<img src="(http[s]?:\/\/[^"]+)"([^>]*)>/g, (match, src, attrs) => {
return `<img src="${src}"${attrs} style="max-width: 100%; height: auto;" />`
})
return html
}
// 瀵煎嚭涓?DOCX (浣跨敤 HTML 鏍煎紡锛學ord 鍙互鐩存帴鎵撳紑)
const exportToDocx = async () => {
if (!crepe) return
showExportDropdown.value = false
try {
const markdown = await crepe.getMarkdown()
const html = await markdownToHtml(markdown)
// 鍖呰 HTML 涓哄畬鏁存枃妗o紝娣诲姞 Word 鍏煎鐨勫厓鏁版嵁
const fullHtml = `
<html xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word"
xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8">
<title>Document</title>
<!--[if gte mso 9]>
<xml>
<w:WordDocument>
<w:View>Print</w:View>
</w:WordDocument>
</xml>
<![endif]-->
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; padding: 40px; }
h1, h2, h3, h4, h5, h6 { margin-top: 1em; margin-bottom: 0.5em; font-weight: 600; }
p { margin: 1em 0; }
code { background-color: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; }
pre { background-color: #f5f5f5; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background-color: transparent; padding: 0; }
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding-left: 16px; color: #666; }
img { max-width: 100%; height: auto; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background-color: #f5f5f5; font-weight: 600; }
.math-block { display: block; margin: 1em 0; text-align: center; overflow-x: auto; }
.math-inline { padding: 0 2px; }
</style>
</head>
<body>
${html}
</body>
</html>`
// 浣跨敤 HTML 鏍煎紡淇濆瓨涓?.doc锛學ord 鍙互鐩存帴鎵撳紑
const blob = new Blob([fullHtml], { type: 'application/msword' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
a.href = url
a.download = `document${datePart}${timePart}.doc`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (error) {
console.error('[Export DOCX] Error:', error)
alert('瀵煎嚭 DOCX 澶辫触锛岃閲嶈瘯')
}
}
// 瀵煎嚭涓?PDF
const exportToPdf = async () => {
if (!crepe) return
showExportDropdown.value = false
try {
const markdown = await crepe.getMarkdown()
const html = await markdownToHtml(markdown)
const fullHtml = `
<html>
<head>
<meta charset="utf-8">
<title>Document</title>
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; padding: 40px; color: #111; background: #fff; }
h1, h2, h3, h4, h5, h6 { margin-top: 1em; margin-bottom: 0.5em; font-weight: 600; }
p { margin: 1em 0; }
code { background-color: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; }
pre { background-color: #f5f5f5; padding: 16px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
pre code { background-color: transparent; padding: 0; }
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding-left: 16px; color: #666; }
img { max-width: 100%; height: auto; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background-color: #f5f5f5; font-weight: 600; }
.math-block { display: block; margin: 1em 0; text-align: center; overflow-x: auto; }
.math-inline { padding: 0 2px; }
</style>
</head>
<body>
${html}
</body>
</html>`
// 閰嶇疆 PDF 閫夐」
const options = {
margin: [10, 10, 10, 10],
filename: `document${new Date().toISOString().slice(0, 10)}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff', windowWidth: 1200 },
pagebreak: { mode: ['css', 'legacy'] },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
}
// 鐢熸垚 PDF
await html2pdf().set(options).from(fullHtml, 'string').save()
} catch (error) {
console.error('[Export PDF] Error:', error)
alert('瀵煎嚭 PDF 澶辫触锛岃閲嶈瘯')
}
}
const triggerUpload = () => {
fileInputRef.value?.click()
}
@@ -559,6 +992,10 @@ const toggleAI = async () => {
})
}
const toggleExportDropdown = () => {
showExportDropdown.value = !showExportDropdown.value
}
const toggleImageDropdown = () => {
showImageDropdown.value = !showImageDropdown.value
}
@@ -630,6 +1067,21 @@ onUnmounted(() => {
rootResizeObserver = null
}
if (mermaidResizeTimer) {
clearTimeout(mermaidResizeTimer)
mermaidResizeTimer = null
}
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
if (root.value) {
root.value.removeEventListener('click', handleMermaidAction, true)
}
window.removeEventListener('resize', handleMermaidViewportResize)
for (const url of Array.from(objectUrls)) {
revokeObjectUrl(url)
}
@@ -791,6 +1243,40 @@ onUnmounted(() => {
opacity: 1;
}
.export-btn-wrapper {
position: relative;
}
.export-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
box-shadow: var(--panel-shadow);
overflow: hidden;
z-index: 10000;
min-width: 160px;
}
.export-dropdown button {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 14px;
color: var(--app-text);
}
.export-dropdown button:hover {
background: var(--crepe-color-hover);
}
.image-btn-wrapper {
position: relative;
}
@@ -900,6 +1386,60 @@ onUnmounted(() => {
filter: brightness(0.92);
}
.mermaid-preview-overlay {
position: fixed;
inset: 0;
z-index: 10002;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.mermaid-preview-dialog {
width: min(96vw, 1280px);
max-height: min(92vh, 920px);
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: var(--panel-shadow);
display: flex;
flex-direction: column;
position: relative;
}
.mermaid-preview-close {
position: absolute;
top: 8px;
right: 8px;
width: 34px;
height: 34px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-fg);
cursor: pointer;
z-index: 1;
}
.mermaid-preview-close:hover {
background: var(--btn-hover-bg);
color: var(--btn-hover-fg);
border-color: var(--btn-hover-bg);
}
.mermaid-preview-scroll {
overflow: auto;
padding: 40px 16px 16px;
}
.mermaid-preview-scroll img {
display: block;
max-width: none;
margin: 0 auto;
}
.milkdown-editor {
--editor-tail-space: calc(100vh - 32px);
width: 100%;
@@ -937,10 +1477,14 @@ onUnmounted(() => {
}
.milkdown-editor :deep(.ProseMirror img) {
max-width: 60%;
max-width: 80%;
height: auto;
}
.milkdown-editor :deep(.ProseMirror .mermaid-image) {
max-width: none !important;
}
.milkdown-editor :deep(.ProseMirror > *:first-child) {
margin-top: 0 !important;
}
@@ -1046,3 +1590,6 @@ onUnmounted(() => {
}
</style>
-3
View File
@@ -183,9 +183,6 @@ function normalizeSuggestionText(raw: string): string {
if (!text.includes('\n') && text.includes('\\n')) {
text = text.replace(/\\n/g, '\n')
}
if (text.includes('\\t')) {
text = text.replace(/\\t/g, '\t')
}
return text
}
+330 -43
View File
@@ -1,67 +1,354 @@
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import mermaid from 'mermaid'
// ── Mermaid init ────────────────────────────────────────────────────────────
let mermaidReady = false
let mermaidReadyTheme = ''
let diagramCounter = 0
let renderCounter = 0
type MermaidImagePayload = {
previewUrl: string
downloadUrl: string | null
width: number
height: number
sourceWidth: number
sourceHeight: number
filename: string
}
function getMermaidTheme() {
const rootTheme = document.documentElement.getAttribute('data-theme')
return rootTheme === 'dark' ? 'dark' : 'default'
}
function ensureMermaid() {
if (mermaidReady) return
const theme = getMermaidTheme()
if (mermaidReadyTheme === theme) return
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'default',
theme: theme || (dark ? 'dark' : 'default'),
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: {
htmlLabels: false,
},
})
mermaidReady = true
mermaidReadyTheme = theme
}
// ── renderPreview ───────────────────────────────────────────────────────────
// Pass this function to codeBlockConfig.renderPreview via crepe.editor.config().
// For non-mermaid languages, return null to use the default preview renderer.
function encodeMermaidCode(code: string) {
return encodeURIComponent(code)
}
export async function mermaidRenderPreview(
function decodeMermaidCode(code: string) {
try {
return decodeURIComponent(code)
} catch {
return code
}
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function nowFileStamp() {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
}
function makeMermaidFilename() {
return `mermaid-${nowFileStamp()}.png`
}
function buildMermaidPreviewMarkup(code: string, token: number) {
const encoded = encodeMermaidCode(code)
const filename = makeMermaidFilename()
return `
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
<div class="mermaid-controls">
<button type="button" class="mermaid-action-btn" data-mermaid-action="zoom" disabled>Zoom</button>
<button type="button" class="mermaid-action-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled>Download PNG</button>
</div>
<div class="mermaid-inner">
<div class="mermaid-loading">...</div>
</div>
</div>`.trim()
}
function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload | null) {
const actionNodes = block.querySelectorAll<HTMLElement>('[data-mermaid-action]')
actionNodes.forEach((node) => {
const action = node.getAttribute('data-mermaid-action')
if (action === 'zoom') {
if (payload) {
node.removeAttribute('disabled')
node.removeAttribute('title')
node.setAttribute('data-mermaid-url', payload.previewUrl)
} else {
node.setAttribute('disabled', 'true')
node.removeAttribute('title')
node.removeAttribute('data-mermaid-url')
}
return
}
if (action === 'download') {
node.textContent = 'Download PNG'
if (payload) {
node.removeAttribute('disabled')
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
node.setAttribute('data-mermaid-filename', payload.filename)
if (payload.downloadUrl) {
node.removeAttribute('title')
} else {
node.setAttribute('title', 'PNG will be generated on download')
}
} else {
node.setAttribute('disabled', 'true')
node.removeAttribute('data-mermaid-url')
node.removeAttribute('title')
}
return
}
if (payload) {
node.removeAttribute('disabled')
} else {
node.setAttribute('disabled', 'true')
node.removeAttribute('data-mermaid-url')
}
})
}
function getSvgSize(svg: string) {
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
if (viewBox) {
const width = Number(viewBox[1])
const height = Number(viewBox[2])
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { width, height }
}
}
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
const width = widthAttr ? Number(widthAttr[1]) : 960
const height = heightAttr ? Number(heightAttr[1]) : 540
return {
width: Number.isFinite(width) && width > 0 ? width : 960,
height: Number.isFinite(height) && height > 0 ? height : 540,
}
}
function svgToDataUrl(svg: string) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
function stripExternalSvgResources(svg: string) {
return svg
.replace(/<image\b[^>]*(?:href|xlink:href)\s*=\s*["']https?:\/\/[^"']*["'][^>]*>/gi, '')
.replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none')
}
function getRasterDpr(width: number, height: number) {
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
const baseDpr = Math.min(3, Math.max(1, rawDpr))
const maxCanvasEdge = 4096
const edgeLimitedDpr = maxCanvasEdge / Math.max(width, height, 1)
return Math.max(1, Math.min(baseDpr, edgeLimitedDpr))
}
async function rasterizeSvgToPngDataUrl(svg: string, width: number, height: number): Promise<string> {
const image = new Image()
image.decoding = 'async'
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = () => reject(new Error('Failed to load rendered SVG'))
image.src = svgToDataUrl(svg)
})
const dpr = getRasterDpr(width, height)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width * dpr))
canvas.height = Math.max(1, Math.round(height * dpr))
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Canvas context unavailable')
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.clearRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
return canvas.toDataURL('image/png')
}
async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
const fallback = getSvgSize(svg)
const sourceWidth = fallback.width
const sourceHeight = fallback.height
let width = sourceWidth
let height = sourceHeight
const maxEdge = 2400
const scale = Math.min(1, maxEdge / Math.max(width, height))
width = Math.max(1, Math.round(width * scale))
height = Math.max(1, Math.round(height * scale))
const normalizedSvg = stripExternalSvgResources(svg)
const candidates = normalizedSvg === svg ? [svg] : [svg, normalizedSvg]
let lastError: unknown = null
for (const candidate of candidates) {
try {
const pngUrl = await rasterizeSvgToPngDataUrl(candidate, width, height)
return {
previewUrl: pngUrl,
downloadUrl: pngUrl,
width,
height,
sourceWidth,
sourceHeight,
filename: makeMermaidFilename(),
}
} catch (err) {
lastError = err
}
}
const message = lastError instanceof Error ? lastError.message.toLowerCase() : String(lastError).toLowerCase()
if (message.includes('tainted') || message.includes('security')) {
return {
previewUrl: svgToDataUrl(svg),
downloadUrl: null,
width,
height,
sourceWidth,
sourceHeight,
filename: makeMermaidFilename(),
}
}
throw lastError instanceof Error ? lastError : new Error('Failed to convert Mermaid diagram to PNG')
}
function getViewportWidth() {
const docWidth = document.documentElement?.clientWidth ?? 0
const bodyWidth = document.body?.clientWidth ?? 0
const winWidth = window.innerWidth ?? 0
return Math.max(docWidth, bodyWidth, winWidth, 1)
}
function getDisplayWidthPx(payload: MermaidImagePayload) {
const threshold = Math.max(1, Math.floor(getViewportWidth() * 0.8))
if (payload.sourceWidth > threshold) {
return Math.min(payload.width, threshold)
}
return Math.min(payload.width, payload.sourceWidth)
}
async function renderMermaidBlock(block: HTMLElement, token: number): Promise<void> {
const tokenOnBlock = Number(block.getAttribute('data-mermaid-token') || '0')
if (tokenOnBlock !== token) return
const inner = block.querySelector('.mermaid-inner')
if (!(inner instanceof HTMLElement)) return
const encodedCode = block.getAttribute('data-mermaid-code') || ''
const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B'
inner.innerHTML = '<div class="mermaid-loading">...</div>'
setMermaidActionsState(block, null)
try {
ensureMermaid()
const id = `mermaid-render-${++diagramCounter}`
const { svg } = await mermaid.render(id, code)
const imagePayload = await svgToImageDataUrl(svg)
const latestToken = Number(block.getAttribute('data-mermaid-token') || '0')
if (latestToken !== token) return
const displayWidth = Math.max(1, Math.round(getDisplayWidthPx(imagePayload)))
const displayHeight = Math.max(1, Math.round((imagePayload.height / Math.max(1, imagePayload.width)) * displayWidth))
inner.innerHTML = `<img class="mermaid-image" src="${imagePayload.previewUrl}" alt="Mermaid diagram" width="${displayWidth}" height="${displayHeight}" style="width:${displayWidth}px;height:auto;">`
block.setAttribute('data-mermaid-width', String(imagePayload.width))
block.setAttribute('data-mermaid-height', String(imagePayload.height))
block.setAttribute('data-mermaid-source-width', String(imagePayload.sourceWidth))
block.setAttribute('data-mermaid-source-height', String(imagePayload.sourceHeight))
block.setAttribute('data-mermaid-display-width', String(displayWidth))
block.setAttribute('data-mermaid-url', imagePayload.previewUrl)
block.style.removeProperty('width')
block.style.removeProperty('max-width')
setMermaidActionsState(block, imagePayload)
} catch (err) {
const latestToken = Number(block.getAttribute('data-mermaid-token') || '0')
if (latestToken !== token) return
const message = err instanceof Error ? err.message : String(err)
inner.innerHTML = `<pre class="mermaid-error">Mermaid error:\n${escapeHtml(message)}</pre>`
setMermaidActionsState(block, null)
}
}
function scheduleMermaidRender(token: number) {
const maxAttempts = 24
const targetSelector = `.mermaid-block[data-mermaid-token="${token}"]`
const run = (attempt: number) => {
const block = document.querySelector(targetSelector)
if (block instanceof HTMLElement) {
void renderMermaidBlock(block, token)
return
}
if (attempt >= maxAttempts) return
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => run(attempt + 1))
} else {
window.setTimeout(() => run(attempt + 1), 16)
}
}
run(0)
}
export function mermaidRenderPreview(
language: string,
content: string,
applyPreview: (value: null | string | HTMLElement) => void,
): Promise<void> {
): void | null {
if (language !== 'mermaid') {
applyPreview(null)
return
return null
}
ensureMermaid()
// Show a placeholder immediately
const wrapper = document.createElement('div')
wrapper.className = 'mermaid-block'
const inner = document.createElement('div')
inner.className = 'mermaid-inner'
inner.innerHTML = '<div class="mermaid-loading">···</div>'
wrapper.appendChild(inner)
applyPreview(wrapper)
const id = `mermaid-render-${++diagramCounter}`
const code = content.trim() || 'graph TD\nA-->B'
try {
const { svg } = await mermaid.render(id, code)
inner.innerHTML = svg
applyPreview(wrapper)
} catch (err) {
const pre = document.createElement('pre')
pre.className = 'mermaid-error'
pre.textContent = `Mermaid error:\n${err instanceof Error ? err.message : String(err)}`
inner.innerHTML = ''
inner.appendChild(pre)
applyPreview(wrapper)
}
const token = ++renderCounter
applyPreview(buildMermaidPreviewMarkup(code, token))
scheduleMermaidRender(token)
}
export function refreshMermaidPreviews() {
const blocks = document.querySelectorAll<HTMLElement>('.mermaid-block[data-mermaid-code]')
blocks.forEach((block) => {
const token = ++renderCounter
block.setAttribute('data-mermaid-token', String(token))
void renderMermaidBlock(block, token)
})
}
// ── Milkdown plugin helper ─────────────────────────────────────────────────
// Call this inside a crepe.editor.config() callback:
// ctx.update(codeBlockConfig.key, (prev) => ({ ...prev, renderPreview: mermaidRenderPreview }))
//
// Re-export the config key so callers don't need to import @milkdown/components directly.
export { codeBlockConfig }
+106 -12
View File
@@ -37,8 +37,16 @@
--toggle-moon: #475569;
--ghost-text: #7d8796;
--ghost-code-bg: rgba(15, 23, 42, 0.06);
--mermaid-max-width: 800px;
--mermaid-max-height: 420px;
--mermaid-mobile-max-height: 320px;
--mermaid-action-bg: linear-gradient(180deg, #ffffff 0%, #f4f7fc 100%);
--mermaid-action-hover-bg: linear-gradient(180deg, #ffffff 0%, #e9f2ff 100%);
--mermaid-action-fg: #1f2937;
--mermaid-action-border: #cfd8e6;
--mermaid-action-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
--mermaid-action-shadow-hover: 0 4px 10px rgba(37, 99, 235, 0.16);
--mermaid-action-disabled-bg: rgba(148, 163, 184, 0.18);
--mermaid-action-disabled-fg: #9aa4b2;
--crepe-color-background: #ffffff;
--crepe-color-on-background: #000000;
@@ -87,6 +95,14 @@
--toggle-moon: #e2e8f0;
--ghost-text: #95a0b4;
--ghost-code-bg: rgba(226, 232, 240, 0.12);
--mermaid-action-bg: linear-gradient(180deg, #30394b 0%, #242c3a 100%);
--mermaid-action-hover-bg: linear-gradient(180deg, #3a4760 0%, #2a3445 100%);
--mermaid-action-fg: #e5e7eb;
--mermaid-action-border: #3e4a61;
--mermaid-action-shadow: 0 1px 2px rgba(2, 6, 23, 0.45);
--mermaid-action-shadow-hover: 0 5px 12px rgba(2, 6, 23, 0.55);
--mermaid-action-disabled-bg: rgba(82, 93, 110, 0.3);
--mermaid-action-disabled-fg: #9aa4b2;
--crepe-color-background: #1a1a1a;
--crepe-color-on-background: #e6e6e6;
@@ -195,31 +211,87 @@ body {
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
.mermaid-block {
display: block;
margin: 1em 0;
padding: 16px;
width: fit-content;
max-width: 100%;
margin: 1em auto;
padding: 12px;
background: var(--crepe-color-surface, #f7f7f7);
border: 1px solid var(--panel-border, #d7deea);
border-radius: 8px;
cursor: pointer;
transition: border-color 160ms ease, box-shadow 160ms ease;
user-select: none;
border-radius: 10px;
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.mermaid-block:hover {
border-color: var(--focus-ring, #3b82f6);
}
.mermaid-block.mermaid-selected {
border-color: var(--focus-ring, #3b82f6);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring, #3b82f6) 25%, transparent);
.mermaid-controls {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.mermaid-action-btn {
appearance: none;
border: 1px solid var(--mermaid-action-border);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
background: var(--mermaid-action-bg);
color: var(--mermaid-action-fg);
cursor: pointer;
box-shadow: var(--mermaid-action-shadow);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease, background 160ms ease;
}
.mermaid-action-btn:hover:not([disabled]) {
border-color: var(--focus-ring);
background: var(--mermaid-action-hover-bg);
box-shadow: var(--mermaid-action-shadow-hover);
transform: translateY(-1px);
}
.mermaid-action-btn:active:not([disabled]) {
transform: translateY(0);
}
.mermaid-action-btn[disabled] {
background: var(--mermaid-action-disabled-bg);
color: var(--mermaid-action-disabled-fg);
cursor: not-allowed;
}
.mermaid-inner {
display: block;
max-width: min(100%, var(--mermaid-max-width));
max-width: 100%;
max-height: var(--mermaid-max-height);
margin: 0 auto;
overflow: auto;
border-radius: 8px;
padding: 8px;
background: color-mix(in srgb, var(--crepe-color-background, #fff) 88%, transparent);
}
.mermaid-inner::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.mermaid-inner::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
.mermaid-image {
display: block;
width: auto;
height: auto;
max-width: none;
margin: 0 auto;
}
.mermaid-inner svg {
@@ -252,3 +324,25 @@ body {
white-space: pre-wrap;
word-break: break-word;
}
:root[data-theme='dark'] .milkdown .katex {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .cm-editor,
:root[data-theme='dark'] .milkdown .cm-scroller {
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
color: var(--crepe-color-on-surface);
}
:root[data-theme='dark'] .milkdown .cm-gutters {
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
color: var(--crepe-color-on-surface-variant);
border-right-color: var(--panel-border);
}
@media (max-width: 768px) {
.mermaid-inner {
max-height: var(--mermaid-mobile-max-height);
}
}
+12
View File
@@ -31,6 +31,8 @@ export const translations = {
about: 'About Us',
importMd: 'Import Markdown',
exportMd: 'Export Markdown',
exportDocx: 'Export DOCX',
exportPdf: 'Export PDF',
uploadImg: 'Upload Image',
enableAI: 'Enable AI',
disableAI: 'Disable AI',
@@ -72,6 +74,8 @@ export const translations = {
about: '关于我们',
importMd: '导入 Markdown',
exportMd: '导出 Markdown',
exportDocx: '导出 DOCX',
exportPdf: '导出 PDF',
uploadImg: '上传图片',
enableAI: '启用 AI',
disableAI: '禁用 AI',
@@ -113,6 +117,8 @@ export const translations = {
about: '私たちについて',
importMd: 'Markdownをインポート',
exportMd: 'Markdownをエクスポート',
exportDocx: 'DOCXをエクスポート',
exportPdf: 'PDFをエクスポート',
uploadImg: '画像をアップロード',
enableAI: 'AIを有効化',
disableAI: 'AIを無効化',
@@ -154,6 +160,8 @@ export const translations = {
about: '회사 소개',
importMd: 'Markdown 가져오기',
exportMd: 'Markdown 내보내기',
exportDocx: 'DOCX 내보내기',
exportPdf: 'PDF 내보내기',
uploadImg: '이미지 업로드',
enableAI: 'AI 활성화',
disableAI: 'AI 비활성화',
@@ -195,6 +203,8 @@ export const translations = {
about: 'Über uns',
importMd: 'Markdown importieren',
exportMd: 'Markdown exportieren',
exportDocx: 'DOCX exportieren',
exportPdf: 'PDF exportieren',
uploadImg: 'Bild hochladen',
enableAI: 'KI aktivieren',
disableAI: 'KI deaktivieren',
@@ -236,6 +246,8 @@ export const translations = {
about: 'À propos de nous',
importMd: 'Importer Markdown',
exportMd: 'Exporter Markdown',
exportDocx: 'Exporter DOCX',
exportPdf: 'Exporter PDF',
uploadImg: 'Télécharger image',
enableAI: 'Activer IA',
disableAI: 'Désactiver IA',