Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 122 additions & 1 deletion apps/web/src/components/editor/PreviewPanel.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { DiagramDownloadOverlay } from '@/utils/diagramDownload'
import { highlightPendingBlocks, hljs } from '@md/core'
import { useRenderStore } from '@/stores/render'
import { useUIStore } from '@/stores/ui'
import { setupDiagramDownloadOverlay } from '@/utils/diagramDownload'

defineProps<{
const props = defineProps<{
backLight: boolean
isCoping: boolean
onContentClick: (event: MouseEvent) => void
Expand Down Expand Up @@ -32,6 +34,33 @@ watch(output, () => {
})
})

let diagramOverlay: DiagramDownloadOverlay | null = null

// Pause bar injection for the entire duration of isCoping so that
// processClipboardContent mutations never re-inject bars, and resume
// after the copy pipeline resets the DOM.
watch(() => props.isCoping, (coping) => {
if (coping) {
diagramOverlay?.pause()
}
else {
nextTick(() => diagramOverlay?.resume())
}
})

onMounted(() => {
nextTick(() => {
const outputEl = document.getElementById(`output`)
if (outputEl) {
diagramOverlay = setupDiagramDownloadOverlay(outputEl)
}
})
})

onUnmounted(() => {
diagramOverlay?.cleanup()
})

defineExpose({
previewRef,
})
Expand Down Expand Up @@ -124,3 +153,95 @@ defineExpose({
border-spacing: 0;
}
</style>

<style>
/* Diagram download overlay — unscoped to affect v-html content */
.mermaid-diagram,
.plantuml-diagram {
position: relative;
}

.diagram-download-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-6px);
transition: opacity 0.18s ease, transform 0.18s ease, visibility 0s 0.18s;
}

.mermaid-diagram:hover .diagram-download-bar,
.plantuml-diagram:hover .diagram-download-bar,
.mermaid-diagram:focus-within .diagram-download-bar,
.plantuml-diagram:focus-within .diagram-download-bar {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
Comment on lines +173 to +185
transition: opacity 0.18s ease, transform 0.18s ease, visibility 0s;
}

.diagram-download-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 9px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.88);
color: #444;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
transition: background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease, transform 0.1s ease;
user-select: none;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1;
white-space: nowrap;
}

.diagram-download-btn:hover {
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14), 0 0 0 0.5px rgba(0, 0, 0, 0.08);
color: #111;
}

.diagram-download-btn:active {
transform: scale(0.95);
}

.diagram-download-btn:disabled {
opacity: 0.5;
pointer-events: none;
}

.diagram-btn-loading {
letter-spacing: 0.1em;
opacity: 0.7;
}

/* Dark mode */
.output_night .diagram-download-btn {
background: rgba(30, 32, 38, 0.88);
border-color: rgba(255, 255, 255, 0.1);
color: #b8bcc8;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4), 0 0 0 0.5px rgba(255, 255, 255, 0.04);
}

.output_night .diagram-download-btn:hover {
background: rgba(50, 53, 62, 0.96);
border-color: rgba(255, 255, 255, 0.18);
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
}
</style>
3 changes: 2 additions & 1 deletion apps/web/src/stores/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ export const useExportStore = defineStore(`export`, () => {
if (!el)
return

// 添加临时样式:禁用代码块滚动,启用换行
// 添加临时样式:禁用代码块滚动,启用换行,隐藏仅用于预览的 UI 覆盖层
const style = document.createElement('style')
style.textContent = `
.diagram-download-bar { display: none !important; }
.preview pre.code__pre,
.preview .hljs.code__pre,
.preview pre.code__pre > code,
Expand Down
203 changes: 203 additions & 0 deletions apps/web/src/utils/diagramDownload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Diagram download utilities
* Supports downloading SVG diagrams (Mermaid, PlantUML, etc.) as .svg or .png
*/

const DOWNLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>`

const DIAGRAM_SELECTORS = `.mermaid-diagram, .plantuml-diagram`

// ─── Download helpers ────────────────────────────────────────────────────────

function triggerDownload(url: string, filename: string): void {
const a = document.createElement(`a`)
a.href = url
a.download = filename
a.style.display = `none`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}

function cloneSvg(svgEl: SVGSVGElement): SVGSVGElement {
const clone = svgEl.cloneNode(true) as SVGSVGElement
if (!clone.hasAttribute(`xmlns`))
clone.setAttribute(`xmlns`, `http://www.w3.org/2000/svg`)
return clone
}

export function downloadDiagramSvg(svgEl: SVGSVGElement, filename = `diagram`): void {
const svgStr = new XMLSerializer().serializeToString(cloneSvg(svgEl))
const blob = new Blob([svgStr], { type: `image/svg+xml;charset=utf-8` })
const url = URL.createObjectURL(blob)
triggerDownload(url, `${filename}.svg`)
URL.revokeObjectURL(url)
}

export function downloadDiagramPng(svgEl: SVGSVGElement, filename = `diagram`): Promise<void> {
const rect = svgEl.getBoundingClientRect()
const width = rect.width || svgEl.viewBox?.baseVal?.width || 800
const height = rect.height || svgEl.viewBox?.baseVal?.height || 600
const scale = Math.max(2, window.devicePixelRatio)

const clone = cloneSvg(svgEl)
clone.setAttribute(`width`, String(width))
clone.setAttribute(`height`, String(height))

const svgStr = new XMLSerializer().serializeToString(clone)
// Use a percent-encoded data URL instead of a blob URL: drawing a blob-URL
// image onto canvas triggers a SecurityError ("tainted canvas") in Chromium.
// Data URLs are treated as same-origin and avoid this restriction.
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgStr)}`

return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement(`canvas`)
canvas.width = Math.round(width * scale)
canvas.height = Math.round(height * scale)
const ctx = canvas.getContext(`2d`)
if (!ctx) {
reject(new Error(`No 2D canvas context`))
return
}
ctx.fillStyle = `#ffffff`
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error(`PNG blob generation failed`))
return
}
const url = URL.createObjectURL(blob)
triggerDownload(url, `${filename}.png`)
URL.revokeObjectURL(url)
resolve()
}, `image/png`)
}
img.onerror = () => reject(new Error(`Failed to load SVG as image`))
img.src = dataUrl
})
}

// ─── Download bar DOM injection ───────────────────────────────────────────────

function makeButton(label: string, title: string): HTMLButtonElement {
const btn = document.createElement(`button`)
btn.className = `diagram-download-btn`
btn.type = `button`
btn.title = title
btn.innerHTML = `${DOWNLOAD_ICON}<span>${label}</span>`
return btn
}

function createDownloadBar(container: HTMLElement, idx: number): HTMLDivElement {
const bar = document.createElement(`div`)
bar.className = `diagram-download-bar`

const svgBtn = makeButton(`SVG`, `下载为 SVG`)
svgBtn.addEventListener(`click`, (e) => {
e.preventDefault()
e.stopPropagation()
const svg = container.querySelector<SVGSVGElement>(`svg`)
if (svg)
downloadDiagramSvg(svg, `diagram-${idx}`)
})

const pngBtn = makeButton(`PNG`, `下载为 PNG`)
pngBtn.addEventListener(`click`, async (e) => {
e.preventDefault()
e.stopPropagation()
const svg = container.querySelector<SVGSVGElement>(`svg`)
if (!svg)
return
pngBtn.disabled = true
const saved = pngBtn.innerHTML
pngBtn.innerHTML = `<span class="diagram-btn-loading">•••</span>`
try {
await downloadDiagramPng(svg, `diagram-${idx}`)
}
finally {
pngBtn.disabled = false
pngBtn.innerHTML = saved
}
})

bar.append(svgBtn, pngBtn)
return bar
}

function injectBar(container: HTMLElement, idx: number): void {
if (getComputedStyle(container).position === `static`)
container.style.position = `relative`
container.querySelector(`.diagram-download-bar`)?.remove()
container.appendChild(createDownloadBar(container, idx))
}

// ─── Overlay lifecycle ────────────────────────────────────────────────────────

export interface DiagramDownloadOverlay {
/** Permanently disconnect the observer — call on component unmount. */
cleanup: () => void
/** Remove all bars and stop injection. Call before copying / exporting. */
pause: () => void
/** Re-inject bars and resume injection. Call after copying / exporting. */
resume: () => void
}

/**
* Watches `outputEl` for diagram containers gaining SVG content and injects
* a SVG / PNG download bar into each one.
*/
export function setupDiagramDownloadOverlay(outputEl: HTMLElement): DiagramDownloadOverlay {
let paused = false
let counter = 0

function inject(el: HTMLElement) {
if (!paused && el.querySelector(`svg`) && !el.querySelector(`.diagram-download-bar`))
injectBar(el, ++counter)
}

function scan() {
outputEl.querySelectorAll<HTMLElement>(DIAGRAM_SELECTORS).forEach(inject)
}

scan()

let rafId: ReturnType<typeof requestAnimationFrame> | null = null

const observer = new MutationObserver(() => {
if (paused)
return
if (rafId !== null)
return
rafId = requestAnimationFrame(() => {
rafId = null
if (!paused)
scan()
})
})

observer.observe(outputEl, { childList: true, subtree: true })

return {
cleanup: () => {
if (rafId !== null)
cancelAnimationFrame(rafId)
observer.disconnect()
},
pause: () => {
paused = true
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
outputEl.querySelectorAll(`.diagram-download-bar`).forEach(el => el.remove())
},
resume: () => {
paused = false
scan()
},
}
}
6 changes: 5 additions & 1 deletion apps/web/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ export async function exportPostsAsZip(posts: Array<{ title: string, content: st
*/
export function getHtmlContent(): string {
const element = document.querySelector(`#output`)!
return element.innerHTML
// Clone to avoid mutating the live DOM, then strip injected UI overlays
// (e.g. diagram download bars) that must not appear in exported content.
const clone = element.cloneNode(true) as HTMLElement
clone.querySelectorAll(`.diagram-download-bar`).forEach(el => el.remove())
return clone.innerHTML
}

/**
Expand Down