UnisMindMap/web_ui/src/components/MindMapRenderer.vue

474 lines
11 KiB
Vue

<template>
<div class="mindmap-container">
<div class="mindmap-actions">
<span class="mindmap-subtitle" v-if="content"> {{ nodeCount }} </span>
<el-dropdown @command="handleDownload">
<el-button
type="primary"
size="small"
class="action-button primary"
>
<template #icon>
<el-icon><Download /></el-icon>
</template>
下载
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item>
<el-dropdown-item command="png">PNG 格式</el-dropdown-item>
<el-dropdown-item command="jpeg">JPEG 格式</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
type="default"
size="small"
@click="resetView"
class="action-button secondary"
>
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
重置视图
</el-button>
</div>
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
<div class="empty-state" v-if="!content">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">暂无思维导图内容</p>
<p class="empty-subtext">请先上传并转换文档</p>
</div>
<div class="mindmap-loading" v-if="content && isRendering">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>正在生成导图...</span>
</div>
<svg ref="svgRef" class="markmap-svg" v-show="content"></svg>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
import { Download, Refresh, Document, ArrowDown, Loading } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// markmap
import { Transformer } from 'markmap-lib'
import { Markmap, loadCSS, loadJS } from 'markmap-view'
const props = defineProps({
content: {
type: String,
default: ''
}
})
const emit = defineEmits<{
(e: 'node-edit', payload: { oldText: string; newText: string }): void
}>()
const svgRef = ref<SVGElement | null>(null)
let mmInstance: any = null
let renderTimer: number | undefined
const transformer = new Transformer()
const scale = ref(1)
const isRendering = ref(false)
const nodeCount = computed(() => {
if (!props.content) return 0
return (props.content.match(/^#{1,6}\s+/gm) || []).length
})
const initMarkmap = async () => {
if (!svgRef.value) return
if (!props.content) {
svgRef.value.innerHTML = ''
if (mmInstance && typeof mmInstance.destroy === 'function') {
mmInstance.destroy()
}
mmInstance = null
return
}
isRendering.value = true
try {
const { root } = transformer.transform(props.content)
const { styles, scripts } = transformer.getAssets()
if (styles) loadCSS(styles)
if (scripts) loadJS(scripts)
await nextTick()
const initialExpandLevel = nodeCount.value > 80 ? 2 : -1
if (mmInstance) {
if (typeof mmInstance.setData === 'function') {
mmInstance.setData(root)
mmInstance.fit()
} else {
mmInstance = Markmap.create(svgRef.value, {
autoFit: true,
fitRatio: 0.9,
initialExpandLevel
}, root)
}
} else {
mmInstance = Markmap.create(svgRef.value, {
autoFit: true,
fitRatio: 0.9,
initialExpandLevel
}, root)
}
await nextTick()
svgRef.value?.querySelectorAll('text').forEach((text) => {
text.setAttribute('data-editable-node', 'true')
})
} catch (error) {
console.error('Error initializing markmap:', error)
mmInstance = null
} finally {
isRendering.value = false
}
}
// 监听内容变化自动重绘
watch(() => props.content, () => {
window.clearTimeout(renderTimer)
renderTimer = window.setTimeout(() => {
initMarkmap()
}, 160)
})
onMounted(() => {
initMarkmap()
// 窗口缩放时自动调整导图大小
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.clearTimeout(renderTimer)
window.removeEventListener('resize', handleResize)
if (mmInstance && typeof mmInstance.destroy === 'function') {
mmInstance.destroy()
} else if (mmInstance) {
console.error('mmInstance does not have destroy method:', mmInstance)
}
})
const handleResize = () => {
mmInstance?.fit()
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function createExportSvg() {
const svg = svgRef.value
if (!svg) return null
const svgCopy = svg.cloneNode(true) as SVGElement
if (!svgCopy.getAttribute('xmlns')) {
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
}
const contentGroup = svg.querySelector('g')
const bbox = contentGroup
? (contentGroup as SVGGElement).getBBox()
: svg.getBBox()
const padding = 48
const width = Math.ceil(Math.max(bbox.width + padding * 2, 320))
const height = Math.ceil(Math.max(bbox.height + padding * 2, 240))
const viewBox = `${Math.floor(bbox.x - padding)} ${Math.floor(bbox.y - padding)} ${width} ${height}`
svgCopy.setAttribute('viewBox', viewBox)
svgCopy.setAttribute('width', String(width))
svgCopy.setAttribute('height', String(height))
svgCopy.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
return {
data: new XMLSerializer().serializeToString(svgCopy),
width,
height
}
}
// 下载思维导图
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
const exportSvg = createExportSvg()
if (!exportSvg) return
const time = new Date().getTime()
if (format === 'svg') {
downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${time}.svg`)
return
}
const maxCanvasPixels = 16_000_000
const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height))))
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = Math.ceil(exportSvg.width * scaleFactor)
canvas.height = Math.ceil(exportSvg.height * scaleFactor)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.scale(scaleFactor, scaleFactor)
const img = new Image()
img.onload = () => {
ctx.drawImage(img, 0, 0)
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
canvas.toBlob((blob) => {
if (!blob) return
downloadBlob(blob, `mindmap_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
}, mimeType, 0.96)
}
img.onerror = () => ElMessage.error('导出图片失败')
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(exportSvg.data)
}
// 处理下载命令
const handleDownload = (command: string) => {
if (command === 'svg' || command === 'png' || command === 'jpeg') {
downloadMindMap(command)
}
}
// 重置视图
const resetView = () => {
mmInstance?.fit()
scale.value = 1
}
// 放大
const zoomIn = () => {
if (scale.value < 2) {
scale.value += 0.1
applyZoom()
}
}
// 缩小
const zoomOut = () => {
if (scale.value > 0.5) {
scale.value -= 0.1
applyZoom()
}
}
// 应用缩放
const applyZoom = () => {
if (mmInstance && typeof mmInstance.setScale === 'function') {
mmInstance.setScale(scale.value)
} else if (mmInstance) {
console.error('mmInstance does not have setScale method:', mmInstance)
}
}
// 处理鼠标滚轮缩放
const handleWheel = (event: WheelEvent) => {
event.preventDefault()
const delta = event.deltaY > 0 ? -0.1 : 0.1
if ((scale.value > 0.5 || delta > 0) && (scale.value < 2 || delta < 0)) {
scale.value += delta
applyZoom()
}
}
const handleNodeClick = async (event: MouseEvent) => {
const textNode = (event.target as Element | null)?.closest?.('text')
const oldText = textNode?.textContent?.trim()
if (!oldText || !props.content || oldText === '文档思维导图') return
try {
const { value } = await ElMessageBox.prompt('编辑节点文本', '编辑思维导图节点', {
inputValue: oldText,
confirmButtonText: '确认',
cancelButtonText: '取消',
inputValidator: (value) => Boolean(value.trim()) || '节点文本不能为空'
})
const newText = String(value).trim()
if (newText && newText !== oldText) {
emit('node-edit', { oldText, newText })
}
} catch {
// 用户取消编辑
}
}
</script>
<style scoped>
.mindmap-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #FFFFFF;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
overflow: hidden;
min-height: 0;
}
.mindmap-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
gap: 12px;
}
.mindmap-subtitle {
font-size: 12px;
color: #86909C;
background-color: #F2F3F5;
padding: 2px 8px;
border-radius: 10px;
margin-right: auto;
}
.action-button {
border-radius: 6px;
transition: all 0.3s ease;
height: 32px;
padding: 0 12px;
}
.action-button.primary {
background-color: #165DFF;
border-color: #165DFF;
color: #FFFFFF;
}
.action-button.primary:hover {
background-color: #0E42D2;
border-color: #0E42D2;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(22, 93, 255, 0.3);
}
.action-button.secondary {
background-color: #FFFFFF;
border-color: #C9CDD4;
color: #4E5969;
}
.action-button.secondary:hover {
border-color: #165DFF;
color: #165DFF;
transform: translateY(-1px);
}
.mindmap-content {
flex: 1;
overflow: hidden;
position: relative;
background-color: #F9FAFC;
cursor: grab;
min-height: 0;
}
.mindmap-content:active {
cursor: grabbing;
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.mindmap-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #4E5969;
background: rgba(249, 250, 252, 0.82);
z-index: 2;
}
.loading-icon {
color: #165DFF;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-icon {
font-size: 48px;
color: #C9CDD4;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: #4E5969;
margin: 0;
}
.empty-subtext {
font-size: 14px;
color: #86909C;
margin: 0;
}
.markmap-svg {
width: 100%;
height: 100%;
transition: transform 0.2s ease;
}
.markmap-svg :deep(text[data-editable-node="true"]) {
cursor: text;
}
/* 响应式设计 */
@media (max-width: 768px) {
.mindmap-header {
padding: 12px 16px;
}
.mindmap-title {
font-size: 14px;
}
.mindmap-subtitle {
font-size: 10px;
}
.action-button {
padding: 0 8px;
font-size: 12px;
}
.mindmap-actions {
gap: 4px;
}
}
</style>