mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(ui/chat): mermaid streaming flicker + copy/download buttons
This commit is contained in:
parent
5909cb601e
commit
0cbe271f6f
@ -1958,25 +1958,125 @@ watch(isGenerating, (generating) => {
|
||||
/* ===== Mermaid block ===== */
|
||||
.markdown-body :deep(.mermaid-block) {
|
||||
margin: 14px 0;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--mc-mermaid-bg, #f8fafc);
|
||||
border: 1px solid var(--mc-mermaid-border, #e2e8f0);
|
||||
overflow: hidden;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
background: var(--mc-code-header-bg);
|
||||
border-bottom: 1px solid var(--mc-mermaid-border, #e2e8f0);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--mc-code-lang-color);
|
||||
/* Prevent the header label/buttons from being swept into a text selection
|
||||
that starts in the surrounding markdown — the highlighted-grey selection
|
||||
band would otherwise extend across the whole header row. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__lang) {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__actions) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__download) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--mc-code-copy-color);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__download:hover) {
|
||||
background: var(--mc-code-copy-hover-bg);
|
||||
color: var(--mc-code-copy-hover-color);
|
||||
}
|
||||
/* Pin EVERY icon inside the header to 14×14. Without this, DOMPurify can
|
||||
normalise away the `width="14" height="14"` attrs from the markdown HTML,
|
||||
leaving the SVG to fall back to the UA default 300×150. The button is
|
||||
inline-flex so it grows to fit the icon, and `:hover` then paints a
|
||||
gigantic grey rectangle (which is what user issue #67's follow-up screen-
|
||||
shot showed). Same defence-in-depth as `.code-block__header svg`. */
|
||||
.markdown-body :deep(.mermaid-block__header svg) {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__header > *) {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__body) {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
/* Reserve a stable height so the box doesn't collapse to 0px before the
|
||||
SVG paints — keeps layout stable across the streaming cache-miss →
|
||||
render cycle. */
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block svg) {
|
||||
.markdown-body :deep(.mermaid-block__body svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block.mermaid-error) {
|
||||
.markdown-body :deep(.mermaid-block.mermaid-error .mermaid-block__body) {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
text-align: left;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
display: block;
|
||||
}
|
||||
/* Streaming placeholder: three pulsing dots inside the empty body. The dots
|
||||
render the same DOM string on every v-html update (stable innerHTML) so
|
||||
the box stops "shaking" during streaming. Once the async render fires
|
||||
after stream end, this gets replaced with the actual SVG. */
|
||||
.markdown-body :deep(.mermaid-block__loader) {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__loader-dot) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-mermaid-border, #cbd5e1);
|
||||
animation: mc-mermaid-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__loader-dot:nth-child(2)) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__loader-dot:nth-child(3)) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@keyframes mc-mermaid-pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block__download.is-flash) {
|
||||
background: var(--mc-warning-bg, rgba(255, 159, 67, 0.15));
|
||||
color: var(--mc-warning, #f59e0b);
|
||||
}
|
||||
|
||||
/* ===== KaTeX inline / block ===== */
|
||||
|
||||
@ -179,8 +179,29 @@ const customRenderer = {
|
||||
|
||||
// Mermaid: ship raw source through a placeholder div for the
|
||||
// useMermaidRenderer post-mount step. Skip syntax highlighting entirely.
|
||||
// The header (lang label + Copy + Download SVG) is part of the placeholder
|
||||
// so users can copy the diagram source even before render completes; the
|
||||
// composable paints the SVG into `.mermaid-block__body`.
|
||||
if (infoStr === 'mermaid') {
|
||||
return `<div class="mermaid-block" data-mermaid="${encodeURIComponent(rawCode)}"></div>`
|
||||
const encoded = encodeURIComponent(rawCode)
|
||||
const copySvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
|
||||
const downloadSvg = `<svg width="14" height="14" 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>`
|
||||
return `<div class="mermaid-block" data-mermaid="${encoded}">`
|
||||
+ `<div class="mermaid-block__header">`
|
||||
+ `<span class="mermaid-block__lang">Mermaid</span>`
|
||||
+ `<span class="mermaid-block__actions">`
|
||||
+ `<button class="code-block__copy" type="button" data-code="${encoded}" aria-label="Copy diagram source">`
|
||||
+ copySvg
|
||||
+ `<span class="code-block__copy-text">Copy</span>`
|
||||
+ `</button>`
|
||||
+ `<button class="mermaid-block__download" type="button" data-mermaid-download="1" aria-label="Download SVG">`
|
||||
+ downloadSvg
|
||||
+ `<span class="mermaid-block__download-text">SVG</span>`
|
||||
+ `</button>`
|
||||
+ `</span>`
|
||||
+ `</div>`
|
||||
+ `<div class="mermaid-block__body"></div>`
|
||||
+ `</div>`
|
||||
}
|
||||
|
||||
// ECharts: same pattern, mounted by useEChartsRenderer.
|
||||
@ -298,7 +319,8 @@ const purifyConfig = {
|
||||
ADD_ATTR: [
|
||||
'target', 'rel', 'class',
|
||||
'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug',
|
||||
'data-tex', 'data-mermaid',
|
||||
'data-tex', 'data-mermaid', 'data-mermaid-download',
|
||||
'x1', 'y1', 'x2', 'y2',
|
||||
'aria-label', 'open',
|
||||
'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd',
|
||||
'x', 'y', 'width', 'height', 'rx', 'ry', 'points',
|
||||
|
||||
@ -31,6 +31,50 @@ async function getMermaid(theme: 'dark' | 'default'): Promise<MermaidLib> {
|
||||
|
||||
let renderCounter = 0
|
||||
|
||||
// Module-level cache of rendered SVGs, keyed by raw mermaid source. This is
|
||||
// the anti-flicker fix for STABLE content (history, theme toggles, scroll):
|
||||
// streaming markdown updates use Vue's `v-html`, which destroys and recreates
|
||||
// the entire subtree on every token — including stable `.mermaid-block`
|
||||
// placeholders whose source hasn't changed. Element identity is therefore
|
||||
// useless for dedup; the only stable key is the source string itself. On
|
||||
// every MutationObserver tick we sync-paint from this cache before the
|
||||
// browser repaints, so the user never sees an empty box for an already
|
||||
// rendered diagram.
|
||||
//
|
||||
// For ACTIVELY STREAMING content the source itself changes every token, so
|
||||
// the cache always misses. The streaming flicker is fixed separately by
|
||||
// (a) skipping the async render path while the host message still has a
|
||||
// `.with-cursor` ancestor, and (b) debouncing the async render so it only
|
||||
// fires after content has been stable for STABLE_RENDER_DEBOUNCE_MS.
|
||||
type SvgCacheEntry = { html: string; theme: 'dark' | 'default' }
|
||||
const SVG_CACHE = new Map<string, SvgCacheEntry>()
|
||||
const SVG_CACHE_CAP = 64
|
||||
const STABLE_RENDER_DEBOUNCE_MS = 350
|
||||
|
||||
function cacheGet(src: string, theme: 'dark' | 'default'): string | null {
|
||||
const entry = SVG_CACHE.get(src)
|
||||
if (!entry || entry.theme !== theme) return null
|
||||
// Refresh LRU position.
|
||||
SVG_CACHE.delete(src)
|
||||
SVG_CACHE.set(src, entry)
|
||||
return entry.html
|
||||
}
|
||||
|
||||
function cacheSet(src: string, theme: 'dark' | 'default', html: string): void {
|
||||
if (SVG_CACHE.size >= SVG_CACHE_CAP) {
|
||||
const oldest = SVG_CACHE.keys().next().value
|
||||
if (oldest !== undefined) SVG_CACHE.delete(oldest)
|
||||
}
|
||||
SVG_CACHE.set(src, { html, theme })
|
||||
}
|
||||
|
||||
function isInsideStreaming(el: HTMLElement): boolean {
|
||||
// `.with-cursor` is set on the assistant `.msg-content` while the message
|
||||
// is generating (see MessageBubble.vue:141). When it's there, every token
|
||||
// produces a fresh v-html update — rendering now would just flicker.
|
||||
return !!el.closest('.msg-content.with-cursor')
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that observes a container for `.mermaid-block[data-mermaid]`
|
||||
* placeholders (emitted by `useMarkdownRenderer.code()` for ```mermaid fenced
|
||||
@ -46,17 +90,49 @@ export function useMermaidRenderer(containerRef: Ref<HTMLElement | null>) {
|
||||
const mounting = new Set<HTMLElement>()
|
||||
const tracked = new Set<HTMLElement>()
|
||||
let observer: MutationObserver | null = null
|
||||
let asyncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function getBody(el: HTMLElement): HTMLElement {
|
||||
// Renderers built before the header/body split fall back to the wrapper
|
||||
// itself so cached chat history (rendered HTML in DB) still mounts.
|
||||
return (el.querySelector<HTMLElement>('.mermaid-block__body')) || el
|
||||
}
|
||||
|
||||
function paintLoadingPlaceholder(el: HTMLElement) {
|
||||
const body = getBody(el)
|
||||
if (body.dataset.mcLoading === '1') return
|
||||
// Use a stable inline-svg loader so the body has a non-empty paint that
|
||||
// doesn't change between mutations — kills the visible "shake".
|
||||
body.innerHTML = '<div class="mermaid-block__loader" aria-hidden="true">'
|
||||
+ '<span class="mermaid-block__loader-dot"></span>'
|
||||
+ '<span class="mermaid-block__loader-dot"></span>'
|
||||
+ '<span class="mermaid-block__loader-dot"></span>'
|
||||
+ '</div>'
|
||||
body.dataset.mcLoading = '1'
|
||||
}
|
||||
|
||||
function tryMountFromCache(el: HTMLElement, src: string, theme: 'dark' | 'default'): boolean {
|
||||
const cached = cacheGet(src, theme)
|
||||
if (!cached) return false
|
||||
const body = getBody(el)
|
||||
body.innerHTML = cached
|
||||
delete body.dataset.mcLoading
|
||||
el.classList.remove('mermaid-error')
|
||||
el.classList.add('mermaid-ready')
|
||||
rendered.add(el)
|
||||
tracked.add(el)
|
||||
return true
|
||||
}
|
||||
|
||||
async function mountBlock(el: HTMLElement) {
|
||||
if (rendered.has(el) || mounting.has(el)) return
|
||||
mounting.add(el)
|
||||
const src = decodeURIComponent(el.getAttribute('data-mermaid') || '')
|
||||
if (!src.trim()) {
|
||||
mounting.delete(el)
|
||||
return
|
||||
}
|
||||
if (!src.trim()) return
|
||||
|
||||
const theme: 'dark' | 'default' = themeStore.isDark ? 'dark' : 'default'
|
||||
|
||||
mounting.add(el)
|
||||
try {
|
||||
const theme = themeStore.isDark ? 'dark' : 'default'
|
||||
const mermaid = await getMermaid(theme)
|
||||
|
||||
// Pre-parse so we can fall back gracefully on bad input. Without this,
|
||||
@ -70,54 +146,146 @@ export function useMermaidRenderer(containerRef: Ref<HTMLElement | null>) {
|
||||
}
|
||||
const id = `mc-mermaid-${++renderCounter}`
|
||||
const { svg } = await mermaid.render(id, src)
|
||||
el.innerHTML = svg
|
||||
rendered.add(el)
|
||||
tracked.add(el)
|
||||
cacheSet(src, theme, svg)
|
||||
// Re-check the element is still in the DOM — during streaming it may
|
||||
// have been detached by a fresh v-html update before our async render
|
||||
// resolved. The cache write above is what matters; future re-creations
|
||||
// of this source will hit the sync fast path.
|
||||
if (el.isConnected) {
|
||||
const body = getBody(el)
|
||||
body.innerHTML = svg
|
||||
delete body.dataset.mcLoading
|
||||
el.classList.add('mermaid-ready')
|
||||
rendered.add(el)
|
||||
tracked.add(el)
|
||||
}
|
||||
} catch (e) {
|
||||
// Streaming-aware: if the LLM is still emitting tokens the source may
|
||||
// legitimately be incomplete on each pass. Mark this element done so
|
||||
// we don't retry on every mutation; when streaming finishes Vue's v-html
|
||||
// produces a fresh element (different identity) and we'll try again.
|
||||
console.warn('[MermaidRenderer] failed to render — showing source:', e)
|
||||
el.classList.add('mermaid-error')
|
||||
el.textContent = src
|
||||
rendered.add(el)
|
||||
// Source either incomplete (still streaming) or genuinely broken.
|
||||
// Don't mark this element rendered — leave the loading placeholder so
|
||||
// the next stable scan can retry. We only show the source-as-text
|
||||
// fallback if the parent message has finished generating, otherwise
|
||||
// the user briefly sees raw mermaid syntax.
|
||||
if (!isInsideStreaming(el)) {
|
||||
console.warn('[MermaidRenderer] failed to render — showing source:', e)
|
||||
el.classList.add('mermaid-error')
|
||||
getBody(el).textContent = src
|
||||
delete getBody(el).dataset.mcLoading
|
||||
rendered.add(el)
|
||||
} else {
|
||||
paintLoadingPlaceholder(el)
|
||||
}
|
||||
} finally {
|
||||
mounting.delete(el)
|
||||
}
|
||||
}
|
||||
|
||||
function scanAndMount() {
|
||||
/**
|
||||
* Synchronous pass: paint cache hits and loading placeholders. Runs on
|
||||
* every MutationObserver tick — must stay cheap and side-effect-free
|
||||
* besides DOM writes for the blocks it inspects.
|
||||
*/
|
||||
function syncPaint() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const blocks = container.querySelectorAll<HTMLElement>(
|
||||
'.mermaid-block[data-mermaid]:not(.mermaid-error)',
|
||||
)
|
||||
const theme: 'dark' | 'default' = themeStore.isDark ? 'dark' : 'default'
|
||||
blocks.forEach((el) => {
|
||||
if (!rendered.has(el) && !mounting.has(el) && !el.querySelector('svg')) {
|
||||
mountBlock(el)
|
||||
if (rendered.has(el) || mounting.has(el)) {
|
||||
return
|
||||
}
|
||||
const body = getBody(el)
|
||||
if (body.querySelector('svg')) {
|
||||
rendered.add(el)
|
||||
tracked.add(el)
|
||||
return
|
||||
}
|
||||
const src = decodeURIComponent(el.getAttribute('data-mermaid') || '')
|
||||
if (!src.trim()) return
|
||||
if (tryMountFromCache(el, src, theme)) return
|
||||
// No cache: paint a stable loading placeholder so the empty box doesn't
|
||||
// flicker between paints. This is what makes streaming feel calm —
|
||||
// every v-html re-creation lands here and immediately writes the same
|
||||
// loader markup, so the user sees a steady box, not a strobing one.
|
||||
paintLoadingPlaceholder(el)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Slow async pass: kick off mermaid renders for blocks that haven't been
|
||||
* cached yet. Skipped while the host message is still streaming — running
|
||||
* mermaid.render on a half-finished source wastes CPU AND visibly flashes
|
||||
* partial diagrams as elements get destroyed mid-render.
|
||||
*/
|
||||
function asyncRenderPass() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const blocks = container.querySelectorAll<HTMLElement>(
|
||||
'.mermaid-block[data-mermaid]:not(.mermaid-error):not(.mermaid-ready)',
|
||||
)
|
||||
blocks.forEach((el) => {
|
||||
if (rendered.has(el) || mounting.has(el)) return
|
||||
const body = getBody(el)
|
||||
if (body.querySelector('svg')) {
|
||||
rendered.add(el)
|
||||
tracked.add(el)
|
||||
return
|
||||
}
|
||||
if (isInsideStreaming(el)) return
|
||||
mountBlock(el)
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleAsyncPass() {
|
||||
if (asyncTimer) clearTimeout(asyncTimer)
|
||||
asyncTimer = setTimeout(() => {
|
||||
asyncTimer = null
|
||||
asyncRenderPass()
|
||||
}, STABLE_RENDER_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function rebuildAll() {
|
||||
// Theme switch: clear rendered SVGs and re-mount with the new theme.
|
||||
// Don't drop the cache map itself — entries are theme-tagged and the next
|
||||
// render will overwrite the stale one.
|
||||
tracked.forEach((el) => {
|
||||
el.innerHTML = ''
|
||||
const body = getBody(el)
|
||||
body.innerHTML = ''
|
||||
delete body.dataset.mcLoading
|
||||
el.classList.remove('mermaid-ready')
|
||||
rendered.delete(el)
|
||||
})
|
||||
tracked.clear()
|
||||
initializedTheme = null // force re-init with the new theme
|
||||
scanAndMount()
|
||||
syncPaint()
|
||||
scheduleAsyncPass()
|
||||
}
|
||||
|
||||
function attachObserver(container: HTMLElement) {
|
||||
observer?.disconnect()
|
||||
observer = new MutationObserver(() => {
|
||||
nextTick(() => scanAndMount())
|
||||
nextTick(() => {
|
||||
// Sync paint runs every tick — gives stable content cache-hit speed
|
||||
// and gives streaming content a stable loader (not an empty box).
|
||||
syncPaint()
|
||||
// Async render is debounced so streaming tokens don't pile up
|
||||
// concurrent mermaid.render() calls; kicks in ~350 ms after the last
|
||||
// mutation, which is well after token cadence and well before a user
|
||||
// would notice.
|
||||
scheduleAsyncPass()
|
||||
})
|
||||
})
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
scanAndMount()
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
// Watch class changes so removing `.with-cursor` (stream end) triggers
|
||||
// a re-scan even when no childList mutation accompanies it.
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
syncPaint()
|
||||
scheduleAsyncPass()
|
||||
}
|
||||
|
||||
function startObserving() {
|
||||
@ -144,8 +312,153 @@ export function useMermaidRenderer(containerRef: Ref<HTMLElement | null>) {
|
||||
stopThemeWatch()
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
if (asyncTimer) clearTimeout(asyncTimer)
|
||||
asyncTimer = null
|
||||
tracked.clear()
|
||||
}
|
||||
|
||||
return { startObserving, dispose, scanAndMount }
|
||||
return { startObserving, dispose, scanAndMount: () => { syncPaint(); scheduleAsyncPass() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render mermaid source with `htmlLabels: false` so the resulting SVG is
|
||||
* self-contained (uses `<text>` nodes instead of `<foreignObject><div>`).
|
||||
* The on-screen render keeps htmlLabels:true for nicer typography in the
|
||||
* chat UI; but foreignObject HTML labels (a) depend on the host page's CSS
|
||||
* variables, which are gone when the SVG is opened standalone, (b) aren't
|
||||
* rendered at all by macOS Quick Look / Preview, and (c) occasionally trip
|
||||
* XMLSerializer cross-namespace bugs that drop the inner xhtml xmlns. The
|
||||
* net effect of all three is the "blank downloaded SVG" the user reports.
|
||||
*/
|
||||
async function renderSvgForExport(src: string, theme: 'dark' | 'default'): Promise<string | null> {
|
||||
try {
|
||||
const mermaid = await getMermaid(theme)
|
||||
const exportSrc = /^\s*%%\{\s*init\s*:/i.test(src)
|
||||
? src
|
||||
: `%%{init: {"flowchart": {"htmlLabels": false}}}%%\n${src}`
|
||||
const id = `mc-mermaid-export-${++renderCounter}`
|
||||
const { svg } = await mermaid.render(id, exportSrc)
|
||||
return svg
|
||||
} catch (e) {
|
||||
console.warn('[MermaidRenderer] export re-render failed; falling back to live SVG', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExportShape(svgEl: SVGSVGElement, fallbackBox: DOMRect | null) {
|
||||
if (!svgEl.getAttribute('xmlns')) {
|
||||
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
}
|
||||
if (!svgEl.getAttribute('xmlns:xlink')) {
|
||||
svgEl.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
|
||||
}
|
||||
// Prefer viewBox (intrinsic to the diagram) over the live bounding rect,
|
||||
// which depends on the chat layout and can be 0 if the message is in a
|
||||
// collapsed panel.
|
||||
let width = 0
|
||||
let height = 0
|
||||
const vb = (svgEl.getAttribute('viewBox') || '').split(/\s+/).map(Number)
|
||||
if (vb.length === 4 && vb[2] > 0 && vb[3] > 0) {
|
||||
width = Math.round(vb[2])
|
||||
height = Math.round(vb[3])
|
||||
}
|
||||
if ((!width || !height) && fallbackBox) {
|
||||
width = Math.round(fallbackBox.width)
|
||||
height = Math.round(fallbackBox.height)
|
||||
}
|
||||
if (!width || !height) {
|
||||
width = 800
|
||||
height = 600
|
||||
}
|
||||
svgEl.setAttribute('width', String(width))
|
||||
svgEl.setAttribute('height', String(height))
|
||||
// Strip mermaid's `style="max-width: 100%"` — fine inside a sized parent,
|
||||
// collapses to 0 px in standalone viewers.
|
||||
svgEl.style.removeProperty('max-width')
|
||||
if (svgEl.getAttribute('style') === '') {
|
||||
svgEl.removeAttribute('style')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for `.mermaid-block__download` buttons. Synchronously claims
|
||||
* the click (returning true so the caller can early-return) and kicks off
|
||||
* the actual export work async — the export re-renders mermaid with
|
||||
* htmlLabels:false to produce a portable SVG, hence the async path.
|
||||
*
|
||||
* Hosted in this module so callers (ChatConsole, AgentContext) only wire one
|
||||
* line — matching the existing `handleCodeCopy` pattern.
|
||||
*/
|
||||
export function handleMermaidDownload(e: MouseEvent): boolean {
|
||||
const btn = (e.target as HTMLElement | null)?.closest('.mermaid-block__download') as HTMLElement | null
|
||||
if (!btn) return false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void doMermaidDownload(btn)
|
||||
return true
|
||||
}
|
||||
|
||||
async function doMermaidDownload(btn: HTMLElement) {
|
||||
const block = btn.closest('.mermaid-block') as HTMLElement | null
|
||||
if (!block) return
|
||||
const liveSvg = block.querySelector('svg') as SVGSVGElement | null
|
||||
const textEl = btn.querySelector('.mermaid-block__download-text') as HTMLElement | null
|
||||
if (!liveSvg) {
|
||||
flashButton(btn, textEl, 'Wait…')
|
||||
return
|
||||
}
|
||||
const originalText = textEl?.textContent || 'SVG'
|
||||
if (textEl) textEl.textContent = '...'
|
||||
btn.setAttribute('disabled', 'true')
|
||||
try {
|
||||
const src = decodeURIComponent(block.getAttribute('data-mermaid') || '')
|
||||
const theme: 'dark' | 'default' = initializedTheme
|
||||
|| (document.documentElement.classList.contains('dark') ? 'dark' : 'default')
|
||||
|
||||
let exportSvg: SVGSVGElement | null = null
|
||||
if (src) {
|
||||
const svgString = await renderSvgForExport(src, theme)
|
||||
if (svgString) {
|
||||
const tmp = document.createElement('div')
|
||||
tmp.innerHTML = svgString
|
||||
exportSvg = tmp.querySelector('svg') as SVGSVGElement | null
|
||||
}
|
||||
}
|
||||
// Fallback: clone the live SVG. Will still work for diagrams without
|
||||
// foreignObject (sequence/class/state diagrams), and degrades gracefully
|
||||
// for flowcharts (the file at least has the structure).
|
||||
if (!exportSvg) {
|
||||
exportSvg = liveSvg.cloneNode(true) as SVGSVGElement
|
||||
}
|
||||
|
||||
let liveBox: DOMRect | null = null
|
||||
try { liveBox = liveSvg.getBoundingClientRect() } catch { /* ignore */ }
|
||||
ensureExportShape(exportSvg, liveBox)
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(exportSvg)
|
||||
const blob = new Blob([`<?xml version="1.0" encoding="UTF-8"?>\n${xml}`], {
|
||||
type: 'image/svg+xml;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mermaid-${Date.now()}.svg`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
} finally {
|
||||
btn.removeAttribute('disabled')
|
||||
if (textEl) textEl.textContent = originalText
|
||||
}
|
||||
}
|
||||
|
||||
function flashButton(btn: HTMLElement, textEl: Element | null, text: string) {
|
||||
const original = textEl?.textContent || ''
|
||||
if (textEl) textEl.textContent = text
|
||||
btn.classList.add('is-flash')
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('is-flash')
|
||||
if (textEl && original) textEl.textContent = original
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
@ -211,6 +211,7 @@ import { agentApi, agentContextApi } from '@/api/index'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import type { Agent, WorkspaceFile } from '@/types/index'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
import { handleMermaidDownload } from '@/composables/useMermaidRenderer'
|
||||
import { plainTextIcon } from '@/composables/usePixelarticons'
|
||||
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
@ -321,6 +322,7 @@ async function fetchPromptFiles() {
|
||||
}
|
||||
|
||||
function handlePreviewClick(e: MouseEvent) {
|
||||
if (handleMermaidDownload(e)) return
|
||||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||||
if (!btn) return
|
||||
// Same as ChatConsole: the copy button now lives inside <details><summary>
|
||||
|
||||
@ -334,7 +334,7 @@ import TalkMode from '@/components/chat/TalkMode.vue'
|
||||
import ModelSelector from '@/components/chat/ModelSelector.vue'
|
||||
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
||||
import { useKatexRenderer } from '@/composables/useKatexRenderer'
|
||||
import { useMermaidRenderer } from '@/composables/useMermaidRenderer'
|
||||
import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer'
|
||||
|
||||
// ============ Talk Mode ============
|
||||
const showTalkMode = ref(false)
|
||||
@ -1731,6 +1731,10 @@ function formatConversationTime(time?: string) {
|
||||
}
|
||||
|
||||
function handleCodeCopy(e: MouseEvent) {
|
||||
// Mermaid download button shares the same global click delegation. Handle
|
||||
// it first so the SVG export beats the copy-button selector below if the
|
||||
// user happens to click in an area where both ancestors are reachable.
|
||||
if (handleMermaidDownload(e)) return
|
||||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||||
if (!btn) return
|
||||
// The copy button now sits inside <details><summary> for collapsible code
|
||||
|
||||
Loading…
Reference in New Issue
Block a user