feat: add docx and html2pdf.js for document export functionality
This commit is contained in:
+330
-43
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user