# 多种格式的导入导出

支持 JSON 格式的导入导出、专有文件 dmp 格式的导入导出、 xlsx 格式的导入导出、 md 格式的导入导出。
支持导出 pngjpgsvgpdf 格式。

这里使用的数据结构是

export interface Node {
    id: string;
    text: string;
    position: [number, number];
    children: string[];
    size: [number, number]; // 新增尺寸字段 [width, height]
    collapsed: boolean;     // 新增折叠状态
    direction?: 'left' | 'right' | 'none'; // 新增方向属性 (只在 center 布局中使用)
}
export type State = {
    nodes: Record<string, Node>;
    connections: string[];
    selectedNodeId: string | null;
    layoutStyle: 'left-to-right' | 'right-to-left' | 'center' | 'top-to-bottom'; // 新增布局风格属性
}

# JSON

# 导出

构建分层递归的 json 字符串,然后保存到本地。

/**
 * 导出为 JSON 文件
 * @returns void
 */
export const exportAsJSON = () => {
    const state = useMindmapStore.getState();
    // 递归构建分层节点结构
    const buildHierarchy = (nodeId: string): unknown => {
        const node = state.nodes[nodeId];
        if (!node) return null;
        // 构建当前节点的对象
        const currentNode = {
            id: node.id,
            text: node.text,
            position: node.position,
            children: node.children.map(childId => buildHierarchy(childId)).filter(Boolean),
            size: node.size,
            collapsed: node.collapsed,
            direction: node.direction
        };
        return currentNode;
    };
    // 从根节点开始递归构建整个树
    const root = buildHierarchy('root');
    // 构造最终导出的数据
    const data = JSON.stringify({
        nodes: root, // 导出的节点是分层的结构
        connections: state.connections
    }, null, 2);
    // 创建 Blob 并保存文件
    const blob = new Blob([data], { type: 'application/json' });
    saveAs(blob, 'mindmap.json');
};

# 导入

定义 json 节点类型

interface JSONNode {
    id: string;
    text: string;
    position?: [number, number]; // 可选属性
    children?: JSONNode[]; // 子节点
    size?: [number, number]; // 可选属性
    collapsed?: boolean; // 可选属性
    direction?: 'left' | 'right' | 'none'; // 可选属性
}

解析 json 文件内容,返回一个 Promise 对象,解析成功返回一个 State 对象,解析失败返回一个错误信息。
递归构建节点结构,更新到 store 中。

export const importFromJSON = (file: File): Promise<void> => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                // 辅助函数:递归解析子节点
                const parseHierarchy = (node: JSONNode, parsedData: ParsedData, parentId?: string): void => {
                    const { id, text, position, children, size, collapsed, direction } = node;
                    // 创建
                    const currentNode: Node = {
                        id,
                        text,
                        position: position || [0, 0], // 如果没有提供位置,则使用默认值
                        children: [], // 子节点 ID 列表
                        size: size || [200, 60], // 如果没有提供尺寸,则使用默认值
                        collapsed: collapsed || false, // 如果没有提供折叠状态,则使用默认值
                        direction: direction|| 'right', // 方向属性是可选的
                    }
                    // 添加到节点记录
                    parsedData.nodes[id] = currentNode;
                    // 如果有父节点,则建立连接
                    if(parentId) {
                        parsedData.connections.push(`${parentId}---${id}`);
                        parsedData.nodes[parentId].children.push(id);
                    }
                    // 递归处理子节点
                    if(children && Array.isArray(children)) {
                        children.forEach((child) => {
                            parseHierarchy(child, parsedData, id);
                        })
                    }
                }
                // 读取
                const content = e.target?.result as string;
                if(!content) {
                    throw new Error('Empty file content');
                }
                // 解析 JSON 数据
                const data = JSON.parse(content);
                
                // 初始化
                const parsedData: ParsedData = {
                    nodes: {},
                    connections: [],
                }
                if(!data.nodes || !data.connections) {
                    throw new Error('Invalid JSON structure');
                }
                // 解析节点
                parseHierarchy(data.nodes, parsedData);
                // 更新全局状态
                useMindmapStore.setState({
                    nodes: parsedData.nodes,
                    connections: parsedData.connections,
                    selectedNodeId: null,
                    layoutStyle: 'left-to-right',
                });
                resolve();
            } catch (error) {
                console.error('Error reading JSON file:', error);
                reject(new Error('Invalid JSON file'));
            }
        }
        reader.onerror = (error) => reject(error);
        reader.readAsText(file);
    })
}

# dmp

dmp 格式是我这个思维导图的专有格式,它包含节点信息、连线信息、布局信息等。

# 导出

直接将 state 转换为 json 字符串,然后保存到本地。

/**
 * 导出为专有文件 dmp 格式
 * @returns void
 */
export const exportAsDMP = () => {
    const state = useMindmapStore.getState();
    // 构造最终导出的数据
    const data = JSON.stringify({
        nodes: Object.values(state.nodes), // 扁平化为 Node 对象数组
        connections: state.connections,
        selectedNodeId: state.selectedNodeId,
        layoutStyle: state.layoutStyle
    }, null, 2);
    const blob = new Blob([data], {type: 'application/dmp'});
    saveAs(blob, 'mindmap.dmp');
}

# 导入

直接读取文件内容,解析 json 对象,并将对象数组转换到 store 中需要的映射形式,更新 state

/**
 * 导入 DMP 文件
 * @param file - DMP 文件对象
 * @returns Promise<void>
 */
export const importFromDMP = (file: File): Promise<void> => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                // 读取文件内容
                const content = e.target?.result as string;
                if(!content) {
                    throw new Error('Empty file content');
                }
                // 解析 DMP 格式
                const data = JSON.parse(content);
                if(!data.nodes || !data.connections) {
                    throw new Error('Invalid DMP structure');
                }
                // 初始化
                const parsedData: Record<string, Node> = {};
                // 解析节点
                data.nodes.forEach((node: Node) => { // 对象数组转映射
                    parsedData[node.id] = node;
                })
                // 更新全局状态
                useMindmapStore.setState({
                    nodes: parsedData,
                    connections: data.connections,
                    selectedNodeId: data.selectedNodeId,
                    layoutStyle: data.layoutStyle,
                });
                resolve();
            } catch (error) {
                console.error('Error reading DMP file:', error);
                reject(new Error('Invalid DMP file'));
            }
        }
        reader.onerror = (error) => reject(error);
        reader.readAsText(file);
    })
}

# XLSX

XLSX 格式是 Excel 表格的格式,它包含节点信息、连线信息、布局信息等。

# 导出

在导出为 Excel 时需要导入 xlsx 库,然后使用 xlsx 库的 utils.json_to_sheet 方法将数据转换为 sheet ,再使用 xlsx 库的 writeFile 方法将 sheet 写入文件。
分为 nodes 表格和 connections 表格以及 global state 表格。
将这些表格写入文件,并设置文件名。

/**
 * 导出为 Excel
 * @returns void
 */
export const exportAsExcel = () => {
    const state = useMindmapStore.getState();
    const { nodes, connections, selectedNodeId, layoutStyle } = state;
    // Nodes 表格
    const nodesData = Object.values(nodes).map((node) => ({
        id: node.id,
        text: node.text,
        position_x: node.position[0],
        position_y: node.position[1],
        size_width: node.size[0],
        size_height: node.size[1],
        collapsed: node.collapsed,
        direction: node.direction || null,
    }));
    // Connections 表格
    const connectionsData = connections.map((conn) => {
        const [from, to] = conn.split('---');
        return { from, to };
    });
    // Global State 表格
    const globalStateData = [
        { key: 'selectedNodeId', value: selectedNodeId },
        { key: 'layoutStyle', value: layoutStyle },
    ];
    // 创建工作簿
    const workbook = XLSX.utils.book_new();
    // 添加 Nodes 工作表
    const nodesWorksheet = XLSX.utils.json_to_sheet(nodesData);
    XLSX.utils.book_append_sheet(workbook, nodesWorksheet, 'Nodes');
    // 添加 Connections 工作表
    const connectionsWorksheet = XLSX.utils.json_to_sheet(connectionsData);
    XLSX.utils.book_append_sheet(workbook, connectionsWorksheet, 'Connections');
    // 添加 Global State 工作表
    const globalStateWorksheet = XLSX.utils.json_to_sheet(globalStateData);
    XLSX.utils.book_append_sheet(workbook, globalStateWorksheet, 'Global State');
    // 导出文件
    XLSX.writeFile(workbook, 'mindmap_data.xlsx');
}

# 导入

定义数据结构

// 定义解析后的数据类型
interface NodeRow {
    id: string;
    text: string;
    position_x: number;
    position_y: number;
    size_width: number;
    size_height: number;
    collapsed: string; // 字符串 'true' 或 'false'
    direction?: 'left' | 'right' | 'none';
}
interface ConnectionRow {
    from: string;
    to: string;
}
interface GlobalStateRow {
    key: string;
    value: string;
}

由于 xlsx 文件的格式是 ArrayBuffer ,所以需要先将文件内容转换为 ArrayBuffer ,再使用 xlsx 库的 read 方法解析文件。
然后把三个表格分别解析为 NodeConnectionGlobalState 对象,并更新到 store 中。

export const importFromXlsx = (file: File) => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                const data = new Uint8Array(e.target?.result as ArrayBuffer);
                const workbook = XLSX.read(data, { type: 'array' });
                // 解析 Nodes 表格
                const nodesSheet = workbook.Sheets['Nodes'];
                const nodesData = XLSX.utils.sheet_to_json<NodeRow>(nodesSheet);
                console.log(nodesData)
                const nodes: Record<string, Node> = {};
                nodesData.forEach((row) => {
                    nodes[row.id] = {
                        id: row.id,
                        text: row.text,
                        position: [row.position_x, row.position_y],
                        children: [],
                        size: [row.size_width, row.size_height],
                        collapsed: row.collapsed === 'true',
                        direction: row.direction || undefined,
                    };
                });
                // 解析 Connections 表格
                const connectionsSheet = workbook.Sheets['Connections'];
                const connectionsData = XLSX.utils.sheet_to_json<ConnectionRow>(connectionsSheet);
                const connections: string[] = [];
                connectionsData.forEach((row) => {
                    connections.push(`${row.from}---${row.to}`);
                });
                // 更新节点的子节点关系
                connections.forEach((conn) => {
                    const [parentId, childId] = conn.split('---');
                    if (nodes[parentId] && nodes[childId]) {
                        nodes[parentId].children.push(childId);
                    }
                });
                // 解析 Global State 表格
                const globalStateSheet = workbook.Sheets['Global State'];
                if (!globalStateSheet) throw new Error('Missing "Global State" sheet');
                const globalStateData = XLSX.utils.sheet_to_json<GlobalStateRow>(globalStateSheet);
                let selectedNodeId: string | null = null;
                let layoutStyle: 'left-to-right' | 'right-to-left' | 'center' | 'top-to-bottom' = 'left-to-right';
                globalStateData.forEach((row) => {
                    if (row.key === 'selectedNodeId') {
                        selectedNodeId = row.value || null;
                    } else if (row.key === 'layoutStyle') {
                        if (
                            row.value === 'left-to-right' ||
                            row.value === 'right-to-left' ||
                            row.value === 'center' ||
                            row.value === 'top-to-bottom'
                        ) {
                            layoutStyle = row.value as typeof layoutStyle;
                        }
                    }
                });
                // 更新 store 状态
                useMindmapStore.setState({
                    nodes,
                    connections,
                    selectedNodeId,
                    layoutStyle,
                });
                resolve({ nodes, connections });
            } catch (error) {
                console.error('Error reading XLSX file:', error);
                reject(new Error('Invalid XLSX file'));
            }
        };
        reader.onerror = (error) => reject(error);
        reader.readAsArrayBuffer(file);
    });
};

# markdown

# 导出

通过设计标题的级别,就可以区分不同的层级,从而实现不同层级的导出。
这个可以通过递归实现,根节点的标题级别为 1,以此类推

/**
 * 导出 markdown
 * @returns void
 */
export const exportAsMarkdown = () => {
    const state = useMindmapStore.getState();
    const { nodes } = state;
    // 递归生成 Markdown 内容
    const generateMarkdown = (nodeId: string, level = 1) => {
        const node = nodes[nodeId];
        if (!node) return '';
        // 当前节点的标题
        const heading = '#'.repeat(level);
        const metadata = JSON.stringify({
            id: node.id,
            position: node.position,
            size: node.size,
            collapsed: node.collapsed,
            direction: node.direction,
        });
        // 当前节点的 Markdown 表示
        let markdown = `${heading} ${node.text} <!-- ${metadata} -->\n`;
        // 如果节点未折叠,则递归生成子节点
        if (node.children.length > 0) {
            node.children.forEach((childId) => {
                markdown += generateMarkdown(childId, level + 1);
            });
        }
        return markdown;
    };
    // 从根节点开始生成 Markdown
    const rootMarkdown = generateMarkdown('root');
    // 将 Markdown 内容下载为文件
    const blob = new Blob([rootMarkdown], { type: 'text/markdown' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'mindmap.md';
    a.click();
}

# 导入

这里先读文件,读出来是一个字符串,对改字符串进行解析
通过正则表达式对每一行的标题进行匹配,然后根据匹配结果生成节点和连接关系。
匹配内容如下

# 标题内容 <!-- 注释内容 -->

在递归分层时,使用栈来找到父节点的 id,然后把当前节点的 id 添加到父节点的 children 中 (类似于括号匹配)
把解析出来的内容更新到 store 中

/**
 * 导入 Markdown 文件
 * @param file - Markdown 文件对象
 * @returns 
 */
// 导入 Markdown 文件
export const importFromMarkdown = (file: File): Promise<void> => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                const content = e.target?.result as string;
                // 定义解析后的节点和连接
                const nodes: Record<string, Node> = {};
                const connections: string[] = [];
                // 当前层级的节点栈
                const nodeStack: { id: string; level: number }[] = [];
                // 逐行解析 Markdown
                content.split('\n').forEach((line) => {
                    // 匹配标题和元数据
                    const headingMatch = line.match(/^(#+)\s+(.+?)\s+<!--\s+(.+?)\s+-->/);
                    if (!headingMatch) return;
                    const [_, hashes, text, metadata] = headingMatch;
                    console.log(_);
                    const level = hashes.length;
                    // 解析元数据
                    const nodeData = JSON.parse(metadata);
                    const nodeId = nodeData.id;
                    // 创建节点
                    nodes[nodeId] = {
                        id: nodeId,
                        text,
                        position: nodeData.position,
                        children: [],
                        size: nodeData.size,
                        collapsed: nodeData.collapsed,
                        direction: nodeData.direction || undefined,
                    };
                    // 确定父子关系
                    while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) {
                        nodeStack.pop();
                    }
                    if (nodeStack.length > 0) {
                        const parentId = nodeStack[nodeStack.length - 1].id;
                        nodes[parentId].children.push(nodeId);
                        connections.push(`${parentId}---${nodeId}`);
                    }
                    // 将当前节点压入栈
                    nodeStack.push({ id: nodeId, level });
                });
                // 更新 store 状态
                useMindmapStore.setState({
                    nodes,
                    connections,
                    selectedNodeId: null, // 如果需要,可以从其他地方获取
                    layoutStyle: 'left-to-right', // 如果需要,可以从其他地方获取
                });
                resolve();
            } catch (error) {
                console.error('Error reading Markdown file:', error);
                reject(new Error('Invalid Markdown file'));
            }
        };
        reader.onerror = (error) => reject(error);
        reader.readAsText(file);
    });
};

# 图片格式的导出

在导出图片之时都需要对节点在新的 stage 中进行重绘,这样才能保证每个无论在哪里,导出的内容都是正确的。

  1. 规划区域
  • 每个节点位置我们都可以从 store 中知道,所以我们只需要遍历一遍 store ,然后根据节点的位置,找到最大的 xy 坐标,然后根据这个坐标来确定整个区域的大小。
  1. 绘制节点
  • 根据规划的区域,在 stage 中绘制节点,然后根据节点的位置,绘制节点
  1. 绘制连线
  • 根据规划的区域,根据节点的位置,绘制连线

重绘代码:

/**
 * 生成临时的 Konva Stage,包含所有节点和连接线。
 * @returns {Object} 包含 stage 和 layer 的对象
 */
const generateStage = () => {
    try {
        // 从 Store 中获取节点和连接信息
        const { nodes, connections, layoutStyle } = useMindmapStore.getState();
        if (!nodes || Object.keys(nodes).length === 0) {
            console.error('没有节点可以导出');
            return null;
        }
        // 计算所有节点的边界框(只包含可见节点)
        let minX = Infinity,
            minY = Infinity,
            maxX = -Infinity,
            maxY = -Infinity;
        const visibleNodes = new Set<string>(); // 存储可见节点的 ID
        // 递归遍历节点树,收集可见节点
        const traverseNodes = (nodeId: string) => {
            const node = nodes[nodeId];
            if (!node) return; // 如果节点不存在,则跳过
            visibleNodes.add(nodeId); // 标记当前节点为可见
            const [x, y] = node.position;
            const [width, height] = node.size;
            // 更新边界框
            minX = Math.min(minX, x);
            minY = Math.min(minY, y);
            maxX = Math.max(maxX, x + width);
            maxY = Math.max(maxY, y + height);
            // 如果节点未折叠,递归处理子节点
            if (!node.collapsed) {
                node.children.forEach((childId) => traverseNodes(childId));
            }
        };
        traverseNodes('root');
        // 如果没有可见节点,直接返回
        if (visibleNodes.size === 0) {
            console.error('没有可见节点可以导出');
            return null;
        }
        // 计算内容区域的宽高
        const contentWidth = maxX - minX;
        const contentHeight = maxY - minY;
        // 创建临时的 Stage 和 Layer
        const tempContainer = document.createElement('div');
        tempContainer.style.position = 'absolute';
        tempContainer.style.top = '-9999px'; // 隐藏容器
        document.body.appendChild(tempContainer);
        const tempStage = new Konva.Stage({
            container: tempContainer,
            width: contentWidth,
            height: contentHeight,
        });
        const tempLayer = new Konva.Layer();
        tempStage.add(tempLayer);
        // 添加白色背景矩形(防止在 jpg 中透明背景会被染成黑色)
        tempLayer.add(new Konva.Rect({
            x: 0,
            y: 0,
            width: contentWidth,
            height: contentHeight,
            fill: 'white',  // 强制白色背景
            listening: false // 防止影响交互
        }));
        // 绘制可见节点
        visibleNodes.forEach((nodeId) => {
            const node = nodes[nodeId];
            const [x, y] = node.position;
            const [width, height] = node.size;
            const rect = new Konva.Rect({
                x: x - minX, // 调整位置,使内容居中
                y: y - minY,
                width,
                height,
                fill: '#fff',
                stroke: '#000',
                strokeWidth: 2,
            });
            const text = new Konva.Text({
                x: x - minX + 10,
                y: y - minY + 10,
                text: node.text,
                fontSize: 16,
                fill: '#000',
                width: width - 20,
                padding: 5,
            });
            tempLayer.add(rect);
            tempLayer.add(text);
        });
        // 绘制可见连接线
        connections.forEach((conn) => {
            const [fromId, toId] = conn.split('---');
            if (!visibleNodes.has(fromId) || !visibleNodes.has(toId)) return;
            const [startX, startY, endX, endY] = calculateConnectionPoints(
                nodes,
                conn
            );
            const line = new Konva.Shape({
                sceneFunc: (ctx, shape) => {
                    ctx.beginPath();
                    ctx.moveTo(startX - minX, startY - minY); // 调整位置
                    if (layoutStyle === 'top-to-bottom') {
                        ctx.quadraticCurveTo(
                            endX - minX,
                            startY - minY,
                            endX - minX,
                            endY - minY
                        )
                    } else {
                        ctx.quadraticCurveTo(
                            startX - minX,
                            endY - minY,
                            endX - minX,
                            endY - minY
                        )
                    }
                    ctx.fillStrokeShape(shape);
                },
                stroke: 'black',
                strokeWidth: 2,
            });
            tempLayer.add(line);
        });
        tempLayer.batchDraw(); // 强制绘制
        return { stage: tempStage, layer: tempLayer, container: tempContainer };
    } catch (error) {
        console.error('生成 Stage 失败', error);
        return null;
    }
};

# 导出为 png

我们可以得到重绘的 stage ,直接使用 stage.toDataURL() 即可得到一个 png 图片的 base64 字符串,然后通过 download 进行下载。

/**
 * 导出为 png
 * @returns void
 */
export const exportAsPNG = () => {
    const result = generateStage();
    if (!result) return;
    const { stage, container } = result;
    try {
        // 导出图片
        const dataUrl = stage.toDataURL({
            mimeType: 'image/png',
            pixelRatio: 2,
        });
        // 创建下载链接
        const link = document.createElement('a');
        link.href = dataUrl;
        link.download = 'mindmap.png';
        link.click();
    } finally {
        // 清理临时资源
        stage.destroy();
        document.body.removeChild(container);
    }
};

# 导出为 jpg

注意如果没有给 stage 设置一个背景颜色,那么导出的图片会出现黑色背景,所以我们需要先设置一个白色背景。
我们也可以得到重绘的 stage ,直接使用 stage.toDataURL() 即可得到一个 jpg 图片的 base64 字符串,然后通过 download 进行下载。

/**
 * 导出为 jpg
 * @returns void
 */
export const exportAsJPG = () => {
    const result = generateStage();
    if (!result) return;
    const { stage, container } = result;
    try {
        const dataUrl = stage.toDataURL({
            mimeType: 'image/jpeg',
            quality: 1, // 图片质量
            pixelRatio: 2, // 提高分辨率
        });
        const link = document.createElement('a');
        link.href = dataUrl;
        link.download = 'mindmap.jpg';
        link.click();
    } finally {
        // 清理临时资源
        stage.destroy();
        document.body.removeChild(container);
    }
}

# 导出为 pdf

我们可以得到重绘的 stage ,然后使用 jspdf 进行导出。
注意stage 中的宽度大于高度时,采用横向布局导出 pdf,当 stage 中的高度大于宽度时,采用纵向布局导出 pdf。
这是因为在 pdf 中内容的实际宽高比例需要与 PDF 页面的比例匹配

/**
 * 导出为 PDF
 * @returns void
 */
export const exportAsPDF = () => {
    // 生成临时 Stage
    const result = generateStage();
    if (!result) return;
    const { stage, container } = result;
    try {
        // 导出图片为 Data URL
        const dataUrl = stage.toDataURL({
            mimeType: 'image/png',
            pixelRatio: 2, // 提高分辨率
        });
        // 获取 Stage 的宽高(以像素为单位)
        const stageWidthPx = stage.width();
        const stageHeightPx = stage.height();
        // 创建 jsPDF 实例
        // 根据舞台的宽高决定页面方向
        // 在 pdf 中内容的实际宽高比例需要与 PDF 页面的比例匹配
        //pdf 页面的方向决定了 pdf 的宽高比
        let pdf;
        if (stageWidthPx > stageHeightPx) {
            pdf = new jsPDF('l', 'px', [stageWidthPx, stageHeightPx]); // 横向布局
        } else {
            pdf = new jsPDF('p', 'px', [stageHeightPx, stageWidthPx]); // 纵向布局
        }
        // 将图片添加到 PDF 中
        // 注意:由于我们使用的是 'px' 作为单位,所以这里不需要进行单位转换
        pdf.addImage(dataUrl, 'PNG', 0, 0, stageWidthPx, stageHeightPx);
        // 下载 PDF 文件
        pdf.save('mindmap.pdf');
    } finally {
        // 清理临时资源
        stage.destroy();
        document.body.removeChild(container);
    }
};

# 导出为 svg

svg 是一种用于描述二维图形的标记语言,它使用 XML 格式来定义图形的形状、颜色、大小等属性。
这个我目前还不能实现手动导出
这里使用的是 react-konva-to-svg 库,这个库可以将 KonvaStage 导出为 svg
直接使用 exportStageSVG 就可以将 Stage 导出为 svg

/**
 * 导出为 SVG
 * @returns void
 */
export const exportAsSVG = async () => {
    // 生成临时 Stage
    const result = generateStage();
    if (!result) return;
    const { stage, container } = result;
    try {
        // 使用 react-konva-to-svg 导出 SVG
        const svgContent = await exportStageSVG(stage, false, {
            onBefore: ([stage, layer]) => {
                console.log('开始导出 SVG:', stage, layer);
            },
            onAfter: ([stage, layer]) => {
                console.log('SVG 导出完成:', stage, layer);
            },
        });
        // 创建 Blob 对象
        const blob = new Blob([svgContent], { type: 'image/svg+xml' });
        // 下载 SVG 文件
        saveAs(blob, 'mindmap.svg');
    } catch (error) {
        console.error('导出 SVG 时出错:', error);
    } finally {
        // 清理临时资源
        stage.destroy();
        document.body.removeChild(container);
    }
};

🍾至此,思维导图可以支持导出多种格式