# 多种格式的导入导出
支持 JSON
格式的导入导出、专有文件 dmp
格式的导入导出、 xlsx
格式的导入导出、 md
格式的导入导出。
支持导出 png
、 jpg
、 svg
、 pdf
格式。
这里使用的数据结构是
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
方法解析文件。
然后把三个表格分别解析为 Node
、 Connection
和 GlobalState
对象,并更新到 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
中进行重绘,这样才能保证每个无论在哪里,导出的内容都是正确的。
- 规划区域
- 每个节点位置我们都可以从
store
中知道,所以我们只需要遍历一遍store
,然后根据节点的位置,找到最大的x
和y
坐标,然后根据这个坐标来确定整个区域的大小。
- 绘制节点
- 根据规划的区域,在
stage
中绘制节点,然后根据节点的位置,绘制节点
- 绘制连线
- 根据规划的区域,根据节点的位置,绘制连线
重绘代码:
/** | |
* 生成临时的 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
库,这个库可以将 Konva
的 Stage
导出为 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); | |
} | |
}; |
🍾至此,思维导图可以支持导出多种格式