feat: add fullscreen mermaid viewer with zoom, pan, toolbar and export

This commit is contained in:
Li Xianggang
2026-03-02 14:55:55 +08:00
committed by lyingbug
parent db21694dd5
commit d28a370931
4 changed files with 376 additions and 10 deletions

View File

@@ -8,6 +8,7 @@ import { onMounted, ref, nextTick, onUnmounted, onUpdated, watch } from "vue";
import { downKnowledgeDetails, deleteGeneratedQuestion } from "@/api/knowledge-base/index";
import { MessagePlugin, DialogPlugin } from "tdesign-vue-next";
import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL } from '@/utils/security';
import { openMermaidFullscreen } from '@/utils/mermaidViewer';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -260,16 +261,51 @@ watch(() => props.details.md, (newVal) => {
const renderMermaidDiagrams = async () => {
try {
const mermaidElements = mdContentWrap.value?.querySelectorAll('.mermaid');
console.log('[Mermaid] Found mermaid elements:', mermaidElements?.length);
if (mermaidElements && mermaidElements.length > 0) {
await mermaid.run({
nodes: mermaidElements
});
console.log('[Mermaid] Rendering complete');
// 渲染完成后绑定点击事件
nextTick(() => {
bindMermaidClickEvents();
});
}
} catch (error) {
console.error('Mermaid rendering error:', error);
}
};
// Mermaid 点击处理函数 - 必须在 bindMermaidClickEvents 之前定义
const handleMermaidClick = (e: Event) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const svg = target.querySelector('svg');
if (svg) {
openMermaidFullscreen(svg.outerHTML);
}
};
// 为 Mermaid 容器绑定点击全屏事件(绑定在 div 上,不是 SVG 上)
const bindMermaidClickEvents = () => {
if (!mdContentWrap.value) {
console.log('[Mermaid] mdContentWrap is null');
return;
}
// 绑定在 .mermaid div 上,而不是 SVG 上
const mermaidDivs = mdContentWrap.value.querySelectorAll('.mermaid');
console.log('[Mermaid] Found mermaid divs:', mermaidDivs.length);
mermaidDivs.forEach((div, index) => {
const divEl = div as HTMLElement;
divEl.style.cursor = 'pointer';
// 移除旧的事件监听器(避免重复绑定)
divEl.removeEventListener('click', handleMermaidClick);
divEl.addEventListener('click', handleMermaidClick);
console.log(`[Mermaid] Bound click event to div ${index}`);
});
};
// 安全地处理 Markdown 内容(使用 marked
const processMarkdown = (markdownText) => {
if (!markdownText || typeof markdownText !== 'string') return '';

View File

@@ -0,0 +1,234 @@
/**
* Mermaid 图表全屏查看器
* 支持:点击放大、滚轮缩放、鼠标拖拽、高清导出
*/
/**
* 下载 SVG 为 PNG 图片(使用实际渲染尺寸)
*/
const downloadSvgAsImage = async (svgElement: SVGElement, filename = 'mermaid-diagram.png'): Promise<void> => {
// 获取 SVG 实际渲染尺寸
const bbox = svgElement.getBoundingClientRect()
const w = Math.round(bbox.width)
const h = Math.round(bbox.height)
// 克隆 SVG
const svgClone = svgElement.cloneNode(true) as SVGElement
svgClone.setAttribute('width', String(w))
svgClone.setAttribute('height', String(h))
const svgData = new XMLSerializer().serializeToString(svgClone)
const svgDataUri = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData)
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, w, h)
ctx.drawImage(img, 0, 0, w, h)
canvas.toBlob((blob) => {
if (!blob) return
const link = document.createElement('a')
link.download = filename
link.href = URL.createObjectURL(blob)
link.click()
URL.revokeObjectURL(link.href)
resolve()
}, 'image/png')
}
img.src = svgDataUri
})
}
/**
* 显示按钮操作反馈提示
*/
const showBtnFeedback = (btn: HTMLElement, success: boolean, text?: string): void => {
const origColor = btn.style.color
const origTitle = btn.title
btn.style.color = success ? '#07c05f' : '#ef4444'
btn.title = text || (success ? '成功' : '失败')
setTimeout(() => {
btn.style.color = origColor
btn.title = origTitle
}, 1500)
}
/**
* 打开 Mermaid 全屏查看器
*/
export const openMermaidFullscreen = (svgHtml: string): void => {
let scale = 1
let translateX = 0
let translateY = 0
let isDragging = false
let dragStartX = 0
let dragStartY = 0
let dragStartTX = 0
let dragStartTY = 0
const STEP = 0.2
// 创建遮罩层
const overlay = document.createElement('div')
overlay.style.cssText = 'position:fixed;inset:0;zIndex:9999;background:rgba(0,0,0,0.65);overflow:hidden;cursor:grab;'
// 创建工具栏
const toolbar = document.createElement('div')
toolbar.style.cssText = 'position:fixed;top:20px;right:20px;display:flex;gap:6px;zIndex:10001;'
const createBtn = (title: string, icon: string): HTMLButtonElement => {
const btn = document.createElement('button')
btn.title = title
btn.style.cssText = 'display:flex;align-items:center;justify-content:center;width:36px;height:36px;border:1px solid #e5e7eb;border-radius:6px;background:rgba(255,255,255,0.95);color:#6b7280;cursor:pointer;padding:0;box-shadow:0 2px 8px rgba(0,0,0,0.15);'
btn.innerHTML = icon
btn.onmouseenter = () => { btn.style.background = '#f0fdf4'; btn.style.color = '#07c05f' }
btn.onmouseleave = () => { btn.style.background = 'rgba(255,255,255,0.95)'; btn.style.color = '#6b7280' }
return btn
}
const zoomInBtn = createBtn('放大', '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>')
const zoomOutBtn = createBtn('缩小', '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>')
const resetBtn = createBtn('重置', '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>')
const downloadBtn = createBtn('下载图片', '<svg width="18" height="18" 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"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>')
const closeBtn = createBtn('关闭', '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>')
toolbar.append(zoomInBtn, zoomOutBtn, resetBtn, downloadBtn, closeBtn)
// 创建内容区域
const content = document.createElement('div')
content.style.cssText = 'position:absolute;left:50%;top:50%;background:#fff;border-radius:12px;padding:32px;box-shadow:0 8px 32px rgba(0,0,0,0.2);transformOrigin:0 0;'
content.innerHTML = svgHtml
const svgEl = content.querySelector('svg')
if (svgEl) {
svgEl.style.display = 'block'
svgEl.setAttribute('draggable', 'false')
}
overlay.appendChild(toolbar)
overlay.appendChild(content)
document.body.appendChild(overlay)
// 自动适配大小
const margin = 60
const viewW = window.innerWidth - margin * 2
const viewH = window.innerHeight - margin * 2
if (content.offsetWidth > 0 && content.offsetHeight > 0) {
const fitScale = Math.min(viewW / content.offsetWidth, viewH / content.offsetHeight)
scale = Math.max(0.5, Math.min(fitScale, 10))
}
// 应用变换
const applyTransform = () => {
content.style.transform = `translate(calc(-50% + ${translateX}px), calc(-50% + ${translateY}px)) scale(${scale})`
}
applyTransform()
// 缩放按钮事件
zoomInBtn.onclick = (e) => { e.stopPropagation(); scale = Math.min(10, scale + STEP); applyTransform() }
zoomOutBtn.onclick = (e) => { e.stopPropagation(); scale = Math.max(0.2, scale - STEP); applyTransform() }
resetBtn.onclick = (e) => { e.stopPropagation(); scale = 1; translateX = 0; translateY = 0; applyTransform() }
// 下载 - 使用实际渲染尺寸
downloadBtn.onclick = (e) => {
e.stopPropagation()
if (!svgEl) return
downloadSvgAsImage(svgEl)
showBtnFeedback(downloadBtn, true, '下载中...')
}
// 关闭函数
let isClosed = false
const close = () => {
if (isClosed) return
isClosed = true
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('keydown', onEsc)
overlay.remove()
}
closeBtn.onclick = (e) => { e.stopPropagation(); close() }
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
}
document.addEventListener('keydown', onEsc)
// 滚轮缩放
overlay.onwheel = (e) => {
e.preventDefault()
const oldScale = scale
scale = e.deltaY < 0 ? Math.min(10, scale + STEP) : Math.max(0.2, scale - STEP)
const rect = overlay.getBoundingClientRect()
const mx = e.clientX - rect.left - rect.width / 2
const my = e.clientY - rect.top - rect.height / 2
const ratio = 1 - scale / oldScale
translateX += (mx - translateX) * ratio
translateY += (my - translateY) * ratio
applyTransform()
}
// 拖拽
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
translateX = dragStartTX + (e.clientX - dragStartX)
translateY = dragStartTY + (e.clientY - dragStartY)
applyTransform()
}
const onMouseUp = () => {
isDragging = false
overlay.style.cursor = 'grab'
}
overlay.onmousedown = (e) => {
const target = e.target as Element
if (target.closest('button')) return
isDragging = true
dragStartX = e.clientX
dragStartY = e.clientY
dragStartTX = translateX
dragStartTY = translateY
overlay.style.cursor = 'grabbing'
e.preventDefault()
}
// 点击遮罩层关闭
overlay.onclick = (e) => {
const target = e.target as Element
if (target === overlay) {
close()
}
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
/**
* 为 Mermaid 图表绑定点击全屏事件
*/
export const bindMermaidClickEvents = (container: HTMLElement): void => {
if (!container) return
const mermaidDivs = container.querySelectorAll('.mermaid')
mermaidDivs.forEach((div) => {
const divEl = div as HTMLElement
divEl.style.cursor = 'pointer'
const clickHandler = (e: Event) => {
e.stopPropagation()
e.preventDefault()
const svg = divEl.querySelector('svg')
if (svg) {
openMermaidFullscreen(svg.outerHTML)
}
}
divEl.removeEventListener('click', clickHandler)
divEl.addEventListener('click', clickHandler)
})
}

View File

@@ -226,6 +226,7 @@ import { getChunkByIdOnly } from '@/api/knowledge-base';
import { MessagePlugin } from 'tdesign-vue-next';
import { useUIStore } from '@/stores/ui';
import { useI18n } from 'vue-i18n';
import { openMermaidFullscreen } from '@/utils/mermaidViewer';
const router = useRouter();
const uiStore = useUIStore();
@@ -459,10 +460,12 @@ watch(eventStream, (stream) => {
}
});
// 渲染 Mermaid 图表
nextTick(() => {
renderMermaidDiagrams();
});
// 只在会话完成后渲染 Mermaid 图表
if (props.session?.is_completed) {
nextTick(() => {
renderMermaidDiagrams();
});
}
}, { immediate: true, deep: true });
// State for intermediate steps collapse
@@ -1271,14 +1274,37 @@ const renderMarkdown = (content: any): string => {
}
};
// 已渲染的 mermaid 元素 ID 集合
const renderedMermaidIds = new Set<string>();
// 渲染 Mermaid 图表的函数
const renderMermaidDiagrams = async () => {
try {
if (rootElement.value) {
const mermaidElements = rootElement.value.querySelectorAll<HTMLElement>('.mermaid');
if (mermaidElements && mermaidElements.length > 0) {
console.log('[Mermaid] Found mermaid elements:', mermaidElements?.length);
// 过滤出未渲染的元素
const unrenderedElements: HTMLElement[] = [];
mermaidElements.forEach((el) => {
const id = el.id || `mermaid-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (!el.id) {
el.id = id;
}
if (!renderedMermaidIds.has(el.id) && !el.querySelector('svg')) {
renderedMermaidIds.add(el.id);
unrenderedElements.push(el);
}
});
if (unrenderedElements.length > 0) {
await mermaid.run({
nodes: mermaidElements
nodes: unrenderedElements
});
console.log('[Mermaid] Rendering complete for', unrenderedElements.length, 'elements');
// 渲染完成后绑定点击事件
nextTick(() => {
bindMermaidClickEvents();
});
}
}
@@ -1287,6 +1313,34 @@ const renderMermaidDiagrams = async () => {
}
};
// Mermaid 点击处理函数 - 必须在 bindMermaidClickEvents 之前定义
const handleMermaidClick = (e: Event) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const svg = target.querySelector('svg');
if (svg) {
openMermaidFullscreen(svg.outerHTML);
}
};
// 为 Mermaid 容器绑定点击全屏事件(绑定在 div 上,不是 SVG 上)
const bindMermaidClickEvents = () => {
if (!rootElement.value) {
console.log('[Mermaid] rootElement is null');
return;
}
// 绑定在 .mermaid div 上,而不是 SVG 上
const mermaidDivs = rootElement.value.querySelectorAll<HTMLElement>('.mermaid');
console.log('[Mermaid] Found mermaid divs:', mermaidDivs.length);
mermaidDivs.forEach((div, index) => {
div.style.cursor = 'pointer';
// 移除旧的事件监听器(避免重复绑定)
div.removeEventListener('click', handleMermaidClick);
div.addEventListener('click', handleMermaidClick);
console.log(`[Mermaid] Bound click event to div ${index}`);
});
};
// Tool summary - extract key info to display externally
const getToolSummary = (event: any): string => {
if (!event || event.pending || !event.success) return '';

View File

@@ -54,6 +54,7 @@ import deepThink from './deepThink.vue';
import AgentStreamDisplay from './AgentStreamDisplay.vue';
import picturePreview from '@/components/picture-preview.vue';
import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL } from '@/utils/security';
import { openMermaidFullscreen } from '@/utils/mermaidViewer';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
import { useUIStore } from '@/stores/ui';
@@ -302,10 +303,16 @@ const renderMermaidDiagrams = async () => {
try {
if (parentMd.value) {
const mermaidElements = parentMd.value.querySelectorAll('.mermaid');
console.log('[Mermaid] Found mermaid elements:', mermaidElements?.length);
if (mermaidElements && mermaidElements.length > 0) {
await mermaid.run({
nodes: mermaidElements
});
console.log('[Mermaid] Rendering complete');
// 渲染完成后绑定点击事件
nextTick(() => {
bindMermaidClickEvents();
});
}
}
} catch (error) {
@@ -313,11 +320,46 @@ const renderMermaidDiagrams = async () => {
}
};
// 监听内容变化并渲染 Mermaid
watch(() => [props.content, props.session?.content], () => {
nextTick(() => {
renderMermaidDiagrams();
// 已渲染的 mermaid 元素 ID 集合
const renderedMermaidIds = new Set();
// Mermaid 点击处理函数 - 必须在 bindMermaidClickEvents 之前定义
const handleMermaidClick = (e) => {
e.stopPropagation();
const target = e.currentTarget;
const svg = target.querySelector('svg');
if (svg) {
openMermaidFullscreen(svg.outerHTML);
}
};
// 为 Mermaid 容器绑定点击全屏事件(绑定在 div 上,不是 SVG 上)
const bindMermaidClickEvents = () => {
if (!parentMd.value) {
console.log('[Mermaid] parentMd is null');
return;
}
// 绑定在 .mermaid div 上,而不是 SVG 上
const mermaidDivs = parentMd.value.querySelectorAll('.mermaid');
console.log('[Mermaid] Found mermaid divs:', mermaidDivs.length);
mermaidDivs.forEach((div, index) => {
const divEl = div;
divEl.style.cursor = 'pointer';
// 移除旧的事件监听器(避免重复绑定)
divEl.removeEventListener('click', handleMermaidClick);
divEl.addEventListener('click', handleMermaidClick);
console.log(`[Mermaid] Bound click event to div ${index}`);
});
};
// 监听内容变化并渲染 Mermaid - 只在会话完成后渲染
watch(() => [props.content, props.session?.content, props.session?.is_completed], () => {
// 只在会话完成后渲染 mermaid
if (props.session?.is_completed) {
nextTick(() => {
renderMermaidDiagrams();
});
}
}, { immediate: true });
onMounted(async () => {