720 lines
18 KiB
Vue
720 lines
18 KiB
Vue
<template>
|
|
<div class="mindmap-container">
|
|
<div class="content-toolbar mindmap-actions">
|
|
<div class="mindmap-actions-left">
|
|
<span class="content-toolbar-meta">{{ nodeSummary }}</span>
|
|
</div>
|
|
<div class="mindmap-actions-right">
|
|
<el-dropdown @command="handleDownload">
|
|
<el-button
|
|
type="primary"
|
|
size="small"
|
|
class="action-button primary"
|
|
:disabled="!content"
|
|
>
|
|
<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>
|
|
<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: ''
|
|
},
|
|
exportResolution: {
|
|
type: Number,
|
|
default: 0
|
|
}
|
|
})
|
|
|
|
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 totalNodeCount = ref(0)
|
|
const hiddenNodeCount = ref(0)
|
|
|
|
type MarkmapNode = {
|
|
content?: string
|
|
children?: MarkmapNode[]
|
|
payload?: {
|
|
fold?: number
|
|
}
|
|
}
|
|
|
|
const nodeSummary = computed(() => {
|
|
if (!props.content) return '暂无节点'
|
|
return hiddenNodeCount.value > 0
|
|
? `已生成 ${totalNodeCount.value} 个节点,隐藏 ${hiddenNodeCount.value} 个节点`
|
|
: `已生成 ${totalNodeCount.value} 个节点`
|
|
})
|
|
|
|
const updateNodeStats = () => {
|
|
const root = mmInstance?.state?.data as MarkmapNode | undefined
|
|
if (!root || !props.content) {
|
|
totalNodeCount.value = 0
|
|
hiddenNodeCount.value = 0
|
|
return
|
|
}
|
|
|
|
const countSubtree = (node: MarkmapNode): number => {
|
|
const children = node.children || []
|
|
return 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
|
|
}
|
|
|
|
const walkVisible = (node: MarkmapNode): { total: number; hidden: number } => {
|
|
const children = node.children || []
|
|
const total = 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
|
|
|
|
if (node.payload?.fold && children.length > 0) {
|
|
return {
|
|
total,
|
|
hidden: children.reduce((sum, child) => sum + countSubtree(child), 0)
|
|
}
|
|
}
|
|
|
|
const childStats = children.reduce(
|
|
(stats, child) => {
|
|
const childStat = walkVisible(child)
|
|
return {
|
|
total: stats.total + childStat.total,
|
|
hidden: stats.hidden + childStat.hidden
|
|
}
|
|
},
|
|
{ total: 1, hidden: 0 }
|
|
)
|
|
return childStats
|
|
}
|
|
|
|
const stats = walkVisible(root)
|
|
totalNodeCount.value = Math.max(0, stats.total - 1)
|
|
hiddenNodeCount.value = Math.max(0, stats.hidden)
|
|
}
|
|
|
|
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 sourceNodeCount = (props.content.match(/^#{1,6}\s+/gm) || []).length
|
|
const initialExpandLevel = sourceNodeCount > 80 ? 2 : -1
|
|
|
|
if (mmInstance) {
|
|
if (typeof mmInstance.setData === 'function') {
|
|
await mmInstance.setData(root, { initialExpandLevel })
|
|
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')
|
|
})
|
|
updateNodeStats()
|
|
} 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)
|
|
}
|
|
|
|
type ExportFormat = 'svg' | 'png' | 'jpeg'
|
|
|
|
interface ExportBox {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
function getViewportSize(svg: SVGElement) {
|
|
const rect = svg.getBoundingClientRect()
|
|
return {
|
|
width: Math.ceil(Math.max(rect.width, 320)),
|
|
height: Math.ceil(Math.max(rect.height, 240))
|
|
}
|
|
}
|
|
|
|
function cleanExportSvg(svg: SVGElement) {
|
|
if (!svg.getAttribute('xmlns')) {
|
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
}
|
|
svg.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
|
|
}
|
|
|
|
function getBoxFromSvgRect(rect: DOMRect): ExportBox {
|
|
return {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height
|
|
}
|
|
}
|
|
|
|
function transformBox(box: ExportBox, matrix: DOMMatrix): ExportBox {
|
|
const points = [
|
|
new DOMPoint(box.x, box.y).matrixTransform(matrix),
|
|
new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix),
|
|
new DOMPoint(box.x, box.y + box.height).matrixTransform(matrix),
|
|
new DOMPoint(box.x + box.width, box.y + box.height).matrixTransform(matrix)
|
|
]
|
|
const xs = points.map((point) => point.x)
|
|
const ys = points.map((point) => point.y)
|
|
const minX = Math.min(...xs)
|
|
const minY = Math.min(...ys)
|
|
const maxX = Math.max(...xs)
|
|
const maxY = Math.max(...ys)
|
|
return {
|
|
x: minX,
|
|
y: minY,
|
|
width: maxX - minX,
|
|
height: maxY - minY
|
|
}
|
|
}
|
|
|
|
function getIntersection(a: ExportBox, b: ExportBox): ExportBox | null {
|
|
const x = Math.max(a.x, b.x)
|
|
const y = Math.max(a.y, b.y)
|
|
const right = Math.min(a.x + a.width, b.x + b.width)
|
|
const bottom = Math.min(a.y + a.height, b.y + b.height)
|
|
if (right <= x || bottom <= y) return null
|
|
return {
|
|
x,
|
|
y,
|
|
width: right - x,
|
|
height: bottom - y
|
|
}
|
|
}
|
|
|
|
function padBox(box: ExportBox, padding: number, boundary?: ExportBox): ExportBox {
|
|
const padded = {
|
|
x: box.x - padding,
|
|
y: box.y - padding,
|
|
width: box.width + padding * 2,
|
|
height: box.height + padding * 2
|
|
}
|
|
|
|
if (!boundary) return padded
|
|
const clipped = getIntersection(padded, boundary)
|
|
return clipped || box
|
|
}
|
|
|
|
function normalizeBox(box: ExportBox): ExportBox {
|
|
const x = Math.min(box.x, box.x + box.width)
|
|
const y = Math.min(box.y, box.y + box.height)
|
|
return {
|
|
x,
|
|
y,
|
|
width: Math.abs(box.width),
|
|
height: Math.abs(box.height)
|
|
}
|
|
}
|
|
|
|
function isCompleteInViewport(contentBox: ExportBox, viewportBox: ExportBox) {
|
|
const tolerance = 4
|
|
return (
|
|
contentBox.x >= viewportBox.x - tolerance &&
|
|
contentBox.y >= viewportBox.y - tolerance &&
|
|
contentBox.x + contentBox.width <= viewportBox.x + viewportBox.width + tolerance &&
|
|
contentBox.y + contentBox.height <= viewportBox.y + viewportBox.height + tolerance
|
|
)
|
|
}
|
|
|
|
function createFullExportSvg() {
|
|
const svg = svgRef.value
|
|
if (!svg) return null
|
|
|
|
const svgCopy = svg.cloneNode(true) as SVGElement
|
|
cleanExportSvg(svgCopy)
|
|
|
|
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}`
|
|
|
|
const viewportGroupCopy = svgCopy.querySelector('g')
|
|
viewportGroupCopy?.removeAttribute('transform')
|
|
svgCopy.setAttribute('viewBox', viewBox)
|
|
svgCopy.setAttribute('width', String(width))
|
|
svgCopy.setAttribute('height', String(height))
|
|
|
|
return {
|
|
data: new XMLSerializer().serializeToString(svgCopy),
|
|
width,
|
|
height
|
|
}
|
|
}
|
|
|
|
function createVisibleExportSvg() {
|
|
const svg = svgRef.value
|
|
if (!svg) return null
|
|
|
|
const contentGroup = svg.querySelector('g') as SVGGElement | null
|
|
if (!contentGroup) return createFullExportSvg()
|
|
|
|
const viewport = getViewportSize(svg)
|
|
const viewportBox = { x: 0, y: 0, width: viewport.width, height: viewport.height }
|
|
const ctm = contentGroup.getCTM()
|
|
if (!ctm) return createFullExportSvg()
|
|
|
|
const contentBox = transformBox(getBoxFromSvgRect(contentGroup.getBBox()), ctm)
|
|
if (isCompleteInViewport(contentBox, viewportBox)) {
|
|
return createFullExportSvg()
|
|
}
|
|
|
|
const svgCopy = svg.cloneNode(true) as SVGElement
|
|
cleanExportSvg(svgCopy)
|
|
|
|
const screenVisibleBox = padBox(getIntersection(contentBox, viewportBox) || viewportBox, 24, viewportBox)
|
|
const localVisibleBox = normalizeBox(transformBox(screenVisibleBox, ctm.inverse()))
|
|
const localContentBox = getBoxFromSvgRect(contentGroup.getBBox())
|
|
const clippedLocalBox = getIntersection(localVisibleBox, localContentBox) || localVisibleBox
|
|
const visibleBox = padBox(clippedLocalBox, 24)
|
|
const width = Math.ceil(Math.max(visibleBox.width, 320))
|
|
const height = Math.ceil(Math.max(visibleBox.height, 240))
|
|
const viewBox = `${Math.floor(visibleBox.x)} ${Math.floor(visibleBox.y)} ${width} ${height}`
|
|
|
|
const viewportGroupCopy = svgCopy.querySelector('g')
|
|
viewportGroupCopy?.removeAttribute('transform')
|
|
svgCopy.setAttribute('width', String(width))
|
|
svgCopy.setAttribute('height', String(height))
|
|
svgCopy.setAttribute('viewBox', viewBox)
|
|
|
|
return {
|
|
data: new XMLSerializer().serializeToString(svgCopy),
|
|
width,
|
|
height
|
|
}
|
|
}
|
|
|
|
// 下载思维导图
|
|
const downloadMindMap = async (format: ExportFormat = 'svg') => {
|
|
const exportSvg = createVisibleExportSvg()
|
|
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 baseScale = 3
|
|
const maxResolution = Number(props.exportResolution) || 0
|
|
const longEdge = Math.max(exportSvg.width, exportSvg.height)
|
|
const scaleFactor = maxResolution > 0
|
|
? Math.min(baseScale, Math.max(0.1, maxResolution / longEdge))
|
|
: baseScale
|
|
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 (!props.content) {
|
|
ElMessage.warning('暂无思维导图内容可下载')
|
|
return
|
|
}
|
|
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
|
downloadMindMap(command)
|
|
}
|
|
}
|
|
|
|
// 重置视图
|
|
const resetView = () => {
|
|
mmInstance?.fit()
|
|
scale.value = 1
|
|
window.setTimeout(updateNodeStats, 0)
|
|
}
|
|
|
|
// 放大
|
|
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) => {
|
|
window.setTimeout(updateNodeStats, 0)
|
|
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: transparent;
|
|
border-radius: 0;
|
|
box-shadow: none;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
.content-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.mindmap-actions {
|
|
padding: 0 0 12px;
|
|
}
|
|
|
|
.mindmap-actions-left,
|
|
.mindmap-actions-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.mindmap-actions-left {
|
|
flex: 1;
|
|
}
|
|
|
|
.mindmap-actions-right {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.content-toolbar-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #343A40;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.content-toolbar-meta {
|
|
font-size: 12px;
|
|
color: #86909C;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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;
|
|
border: 1px solid #E9ECEF;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.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-actions {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.mindmap-actions-left,
|
|
.mindmap-actions-right {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.content-toolbar-meta {
|
|
font-size: 11px;
|
|
}
|
|
|
|
.action-button {
|
|
padding: 0 8px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.mindmap-actions {
|
|
gap: 4px;
|
|
}
|
|
}
|
|
</style>
|