鸿蒙平台 FreePlane 思维导图适配实战:从桌面到 鸿蒙PC 的 Electron 迁移指南
项目简介
FreePlane 是经典的开源思维导图应用,支持节点编辑、颜色标记、层级布局、撤销/重做、导出等功能。本项目将其从桌面应用迁移到鸿蒙平台,采用 Electron 核心功能 + 鸿蒙壳工程 的架构模式,基于纯 DOM 自研思维导图引擎实现(零 SVG、零 Canvas、零外部库)。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_freeplane_electron
核心功能
- 🌳 思维导图编辑(添加子节点、兄弟节点、删除、编辑文本)
- 🎨 节点颜色标记(9 色色板,支持单个节点着色或全部应用)
- 🔍 页面缩放(按钮/快捷键/鼠标滚轮缩放,30%~300%,实时显示比例)
- ↶↷ 撤销/重做(支持 100 步历史,Ctrl+Z/Y 操作)
- 🖱️ 画布拖拽(空白区域拖拽平移画布,抓手光标交互)
- ⌨️ 键盘导航(方向键遍历节点树,Tab/Enter 快速添加,F2 编辑,Delete 删除)
- 📋 右键菜单(节点右键快捷操作:添加/编辑/删除/颜色)
- 🌙 暗色主题(一键切换亮色/暗色主题,localStorage 持久化)
- 📤 导出 PNG(离屏 Canvas 渲染 → 原生保存对话框 → IPC 写文件)
- 📄 导出 PDF(window.open 打印 → 降级为 PNG 导出)
- 💾 自动保存(Ctrl+S 保存至 userData/freeplane/mindmap.json)
- 🎯 回到中心(一键回到画布中心,快速定位根节点)
- 📊 节点计数(底部状态栏实时显示当前节点数量)
- 🎨 层级颜色(节点边框按深度自动着色,5 级渐变)
一、技术架构
1.1 原始架构(Desktop)
FreePlane (Java Desktop)
├── UI 渲染:Swing/JavaFX Widget
├── 布局引擎:自定义树形布局算法
├── 图形渲染:Java2D / SVG
└── 文件系统:Java NIO
1.2 目标架构(鸿蒙 Electron)
鸿蒙壳工程 (ArkTS)
└── web_engine 模块 (XComponent WebView)
└── Electron 应用 (HTML/CSS/JavaScript)
├── main.js - Electron 主进程
├── renderer.js - 渲染进程(核心逻辑)
├── index.html - UI 界面
├── package.json - 项目配置
└── styles/
└── freeplane.css - 样式文件
1.3 架构优势
- 纯 DOM 实现:零 SVG、零 Canvas、零外部库,仅使用 position: absolute 的 div 元素
- 快速开发:Web 技术栈,开发效率高
- 易于维护:UI 和业务逻辑分离
- 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
- 自研布局引擎:递归树形布局算法,支持左右分支对称分布
二、环境准备
2.1 开发环境要求
- 操作系统:Windows 10
- 开发工具:DevEco Studio(鸿蒙官方 IDE)
- HarmonyOS SDK:API 23(6.1.1)
- Node.js:v20+(Electron 依赖)
2.2 项目结构
ohos_hap/
└── web_engine/ # 鸿蒙 web_engine 模块
└── src/main/resources/
└── resfile/resources/app/ # 部署目录
├── main.js # Electron 主进程
├── renderer.js # 渲染进程(核心逻辑)
├── index.html # UI 界面
└── styles/
└── freeplane.css # 样式文件
└── build-profile.json5 # 鸿蒙构建配置
三、核心适配流程
3.1 第一步:创建 Electron 主进程(main.js)
文件:web_engine/src/main/resources/resfile/resources/app/main.js
// FreePlane Mind Map - Electron 主进程
// 纯 DOM 思维导图应用(鸿蒙适配)
const { app, BrowserWindow, ipcMain, screen, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow = null;
function createWindow() {
console.log('FreePlane: 创建窗口...');
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize;
const windowWidth = Math.floor(screenWidth * 0.95);
const windowHeight = Math.floor(screenHeight * 0.9);
mainWindow = new BrowserWindow({
width: windowWidth,
height: windowHeight,
x: Math.floor((screenWidth - windowWidth) / 2),
y: Math.floor((screenHeight - windowHeight) / 2),
frame: true,
transparent: false,
alwaysOnTop: false,
hasShadow: true,
resizable: true,
focusable: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false
}
});
console.log('FreePlane: 正在加载 index.html:', path.join(__dirname, 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'index.html'));
console.log('FreePlane: 窗口创建成功,尺寸:', windowWidth, 'x', windowHeight);
mainWindow.on('closed', () => {
console.log('FreePlane: 窗口已关闭');
mainWindow = null;
});
mainWindow.webContents.on('did-finish-load', () => {
console.log('FreePlane: 页面加载成功');
});
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('FreePlane: 页面加载失败:', errorCode, errorDescription);
});
setupIpcHandlers();
}
function setupIpcHandlers() {
console.log('FreePlane: 设置 IPC 处理器');
// 自动保存路径(避免原生对话框触发崩溃)
var saveDir = path.join(app.getPath('userData'), 'freeplane');
try { fs.mkdirSync(saveDir, { recursive: true }); } catch(e) {}
var defaultSavePath = path.join(saveDir, 'mindmap.json');
ipcMain.handle('auto-save-path', () => defaultSavePath);
// PNG 导出路径(原生保存对话框,和 Xournal 一致)
ipcMain.handle('export-png-path', async () => {
try {
var result = await dialog.showSaveDialog(mainWindow, {
title: '导出 PNG 图片',
defaultPath: path.join(saveDir, 'mindmap.png'),
filters: [{ name: 'PNG 图片', extensions: ['png'] }]
});
if (result.canceled || !result.filePath) return null;
return result.filePath;
} catch (e) {
console.error('FreePlane: 导出对话框失败:', e);
return null;
}
});
// 写入二进制文件(base64 → Buffer)
ipcMain.handle('write-binary', async (event, filePath, base64) => {
try {
var buf = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, buf);
return true;
} catch (error) {
console.error('FreePlane: 写入二进制文件失败:', error);
return false;
}
});
ipcMain.handle('write-file', async (event, filePath, data) => {
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error('FreePlane: 写入文件失败:', error);
return false;
}
});
}
app.whenReady().then(() => {
createWindow();
console.log('FreePlane 思维导图应用已启动');
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

关键要点:
- 窗口尺寸动态计算(屏幕 95% 宽度 × 90% 高度)
- 提供 4 个核心 IPC 接口:自动保存路径、PNG 导出对话框、二进制写入、JSON 写入
- dialog.showSaveDialog 原生保存对话框(真机已验证稳定)
- Buffer.from(base64, ‘base64’) 实现 PNG 二进制写入
- 全中文日志输出,便于调试
3.2 第二步:设计专业思维导图 UI(index.html)
文件:web_engine/src/main/resources/resfile/resources/app/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FreePlane - 思维导图</title>
<link rel="stylesheet" href="styles/freeplane.css">
</head>
<body>
<div id="app" class="app-container">
<!-- 标题栏 -->
<div class="title-bar">
<span class="logo">◆</span>
<span class="app-title">FreePlane 思维导图</span>
</div>
<!-- 操作栏 -->
<div class="toolbar">
<div class="btn-group">
<button id="btn-new" class="btn" data-tip="新建 (Ctrl+N)">新建</button>
<button id="btn-save" class="btn" data-tip="自动保存 (Ctrl+S)">保存</button>
</div>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-add-child" class="btn" data-tip="添加子节点 (Tab)">+ 子节点</button>
<button id="btn-add-sibling" class="btn" data-tip="添加兄弟 (Enter)">+ 兄弟</button>
</div>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-edit" class="btn" data-tip="编辑 (F2)">编辑</button>
<button id="btn-delete" class="btn btn-danger" data-tip="删除 (Delete)">删除</button>
</div>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-undo" class="btn" data-tip="撤销 (Ctrl+Z)">↶</button>
<button id="btn-redo" class="btn" data-tip="重做 (Ctrl+Y)">↷</button>
</div>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-center" class="btn" data-tip="回到中心">⊕</button>
<button id="btn-theme" class="btn" data-tip="切换主题">☀</button>
<button id="btn-color" class="btn btn-color-trigger" data-tip="节点颜色">●</button>
</div>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-zoom-out" class="btn" data-tip="缩小 (Ctrl+-)">−</button>
<span id="zoom-display" class="zoom-display">100%</span>
<button id="btn-zoom-in" class="btn" data-tip="放大 (Ctrl+=)">+</button>
<button id="btn-zoom-reset" class="btn" data-tip="重置缩放 (Ctrl+0)">⊕</button>
</div>
<span class="sep"></span>
<div class="btn-group">
<button id="btn-export-png" class="btn" data-tip="导出为PNG图片">⤓ PNG</button>
<button id="btn-export-pdf" class="btn" data-tip="导出为PDF文档">⤓ PDF</button>
</div>
</div>
<!-- 工具栏颜色色板 -->
<div id="color-palette" class="color-palette">
<div class="color-palette-title">节点颜色</div>
<div class="color-palette-row">
<span class="cp-dot" data-color="" title="默认" style="background:#fff;border-color:#ccc"></span>
<span class="cp-dot" data-color="#e74c3c" style="background:#e74c3c" title="红"></span>
<span class="cp-dot" data-color="#e67e22" style="background:#e67e22" title="橙"></span>
<span class="cp-dot" data-color="#f1c40f" style="background:#f1c40f" title="黄"></span>
<span class="cp-dot" data-color="#2ecc71" style="background:#2ecc71" title="绿"></span>
<span class="cp-dot" data-color="#3498db" style="background:#3498db" title="蓝"></span>
<span class="cp-dot" data-color="#9b59b6" style="background:#9b59b6" title="紫"></span>
<span class="cp-dot" data-color="#1abc9c" style="background:#1abc9c" title="青"></span>
<span class="cp-dot" data-color="#e91e63" style="background:#e91e63" title="粉"></span>
</div>
<div class="color-palette-actions">
<button id="btn-color-all" class="btn cp-apply-all" title="将颜色应用到所有节点">全部应用</button>
</div>
</div>
<!-- 思维导图画布 -->
<main class="main-content">
<div id="mindmap-canvas"></div>
</main>
<!-- 右键菜单 -->
<div id="ctx-menu" class="ctx-menu">
<div class="ctx-item" data-action="add-child">+ 添加子节点 <span class="ctx-hint">Tab</span></div>
<div class="ctx-item" data-action="add-sibling">+ 添加兄弟 <span class="ctx-hint">Enter</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="edit">编辑节点 <span class="ctx-hint">F2</span></div>
<div class="ctx-item ctx-danger" data-action="delete">删除节点 <span class="ctx-hint">Del</span></div>
<div class="ctx-sep"></div>
<div class="ctx-colors-label">节点颜色</div>
<div class="ctx-colors">
<span class="ctx-color" data-color="" title="默认"></span>
<span class="ctx-color" data-color="#e74c3c" style="background:#e74c3c" title="红色"></span>
<span class="ctx-color" data-color="#e67e22" style="background:#e67e22" title="橙色"></span>
<span class="ctx-color" data-color="#f1c40f" style="background:#f1c40f" title="黄色"></span>
<span class="ctx-color" data-color="#2ecc71" style="background:#2ecc71" title="绿色"></span>
<span class="ctx-color" data-color="#3498db" style="background:#3498db" title="蓝色"></span>
<span class="ctx-color" data-color="#9b59b6" style="background:#9b59b6" title="紫色"></span>
<span class="ctx-color" data-color="#1abc9c" style="background:#1abc9c" title="青色"></span>
<span class="ctx-color" data-color="#e91e63" style="background:#e91e63" title="粉色"></span>
</div>
</div>
<!-- 底部状态栏 -->
<footer class="status-bar">
<span id="status-text">Tab 添加子节点 | Enter 添加兄弟 | F2 编辑 | Delete 删除</span>
<span id="node-count">0 个节点</span>
</footer>
</div>
<script src="renderer.js"></script>
</body>
</html>

关键要点:
- 三栏式布局(标题栏 + 操作栏 + 画布容器 + 底部状态栏)
- 工具栏包含:文件操作(新建/保存)+ 节点编辑(添加子节点/兄弟/编辑/删除)+ 撤销/重做 + 主题/颜色 + 缩放控件 + 导出功能(PNG/PDF)
- 9 色色板(红/橙/黄/绿/蓝/紫/青/粉 + 默认),支持单个节点着色或全部应用
- 右键菜单(添加子节点/兄弟/编辑/删除/颜色)
- data-tip 自定义属性替代 title,避免触发 ArkWeb 原生 tooltip 导致 XComponent 崩溃
- 状态栏显示快捷键提示和节点数量
3.3 第三步:配置项目元信息(package.json)
文件:web_engine/src/main/resources/resfile/resources/app/package.json
{
"name": "freeplane-mindmap",
"version": "1.0.0",
"description": "FreePlane 思维导图 - 纯 DOM 实现(鸿蒙适配)",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --dev"
},
"keywords": ["思维导图", "mindmap", "freeplane"],
"author": "FreePlane(鸿蒙移植版)",
"license": "MIT",
"devDependencies": {
"electron": "^28.0.0"
}
}

关键要点:
- main** 入口**:指定 main.js 为 Electron 主进程入口文件
- scripts 脚本:start 启动生产模式,dev 启动开发模式(带调试参数)
- license 协议:MIT
- electron 版本:^28.0.0(兼容鸿蒙 ArkWeb 的 Electron 版本)
- keywords:包含思维导图、mindmap、freeplane 等中英文搜索关键词
- 零外部依赖:纯 DOM 实现,无需 React/Vue/SVG 库
3.4 第四步:实现渲染进程核心逻辑(renderer.js)
文件:web_engine/src/main/resources/resfile/resources/app/renderer.js
// FreePlane Mind Map - 渲染进程
// 纯 DOM 思维导图引擎(零 SVG、零 canvas、零外部库)
var ipcRenderer = require('electron').ipcRenderer;
// ===== 常量 =====
var H_GAP = 250; // 水平间距
var V_GAP = 50; // 垂直间距(同层节点)
var SIB_GAP = 30; // 兄弟间距
// ===== 全局状态 =====
var root = null;
var selected = null;
var isEditing = false;
var undoStack = [];
var redoStack = [];
var idCounter = 1;
var canvasW = 3000;
var canvasH = 2000;
var canvasCX = 1500;
var canvasCY = 1000;
var editInput = null;
// 缩放状态
var zoomLevel = 1; // 1 = 100%
var ZOOM_STEP = 0.1;
var ZOOM_MIN = 0.3;
var ZOOM_MAX = 3.0;
// 导航方向:right-side 用 R,left-side 用 L
var IN_R = { ArrowRight: 'firstChild', ArrowLeft: 'parent' };
var IN_L = { ArrowRight: 'parent', ArrowLeft: 'firstChild' };
var SIB = { ArrowUp: 'prev', ArrowDown: 'next' };
// ===== 工具函数 =====
function makeNode(text) {
return { id: idCounter++, text: text || '新节点', children: [] };
}
// ===== 历史(撤销/重做)=====
function pushHistory() {
undoStack.push(JSON.stringify(root));
if (undoStack.length > 100) undoStack.shift();
redoStack.length = 0;
}
function undo() {
if (!undoStack.length) return;
redoStack.push(JSON.stringify(root));
var old = JSON.parse(undoStack.pop());
var sid = selected ? selected.id : -1;
root = old;
selected = findNode(root, sid) || root;
render();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(JSON.stringify(root));
var old = JSON.parse(redoStack.pop());
var sid = selected ? selected.id : -1;
root = old;
selected = findNode(root, sid) || root;
render();
}
function findNode(node, id) {
if (!node || id == null) return null;
if (node.id === id) return node;
for (var i = 0; i < node.children.length; i++) {
var f = findNode(node.children[i], id);
if (f) return f;
}
return null;
}
// ===== 布局算法 =====
function layoutTree() {
// 动态计算画布尺寸
var maxDepth = getDepth(root, 0);
var totalNodes = countNodes(root);
canvasW = Math.max(3000, (maxDepth + 1) * H_GAP + 800);
canvasH = Math.max(2000, totalNodes * V_GAP + 600);
canvasCX = canvasW / 2;
canvasCY = canvasH / 2;
var ch = root.children;
root.x = canvasCX;
root.y = canvasCY;
root.side = 'root';
root.inDir = IN_R;
root.outDir = IN_R;
if (ch.length === 0) return;
var half = Math.ceil(ch.length / 2);
var rightCh = ch.slice(0, half);
var leftCh = ch.slice(half);
var rH = calcHeight(rightCh);
var y = canvasCY - rH / 2;
for (var i = 0; i < rightCh.length; i++) {
var h = calcHeight([rightCh[i]]);
layoutSub(rightCh[i], canvasCX + H_GAP, y + h / 2, 'right');
y += h;
}
var lH = calcHeight(leftCh);
y = canvasCY - lH / 2;
for (var i = 0; i < leftCh.length; i++) {
var h = calcHeight([leftCh[i]]);
layoutSub(leftCh[i], canvasCX - H_GAP, y + h / 2, 'left');
y += h;
}
}
function getDepth(node, d) {
if (!node.children.length) return d;
var max = d;
for (var i = 0; i < node.children.length; i++) {
max = Math.max(max, getDepth(node.children[i], d + 1));
}
return max;
}
function countNodes(node) {
var c = 1;
for (var i = 0; i < node.children.length; i++) c += countNodes(node.children[i]);
return c;
}
function calcHeight(nodes) {
var total = 0;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (!n.children.length) {
total += V_GAP;
} else {
total += calcHeight(n.children);
}
}
return Math.max(total, V_GAP);
}
function layoutSub(node, x, y, side) {
node.x = x;
node.y = y;
node.side = side;
node.inDir = (side === 'right') ? IN_R : IN_L;
node.outDir = (side === 'right') ? IN_R : IN_L;
if (!node.children.length) return;
var dir = (side === 'right') ? 1 : -1;
var ch = node.children;
var total = calcHeight(ch);
var cy = y - total / 2;
for (var i = 0; i < ch.length; i++) {
var h = calcHeight([ch[i]]);
layoutSub(ch[i], x + dir * H_GAP, cy + h / 2, side);
cy += h;
}
}
// ===== 渲染 =====
function render() {
var canvas = document.getElementById('mindmap-canvas');
canvas.style.width = canvasW + 'px';
canvas.style.height = canvasH + 'px';
canvas.innerHTML = '';
canvas.style.transform = 'scale(' + zoomLevel + ')';
canvas.style.transformOrigin = '0 0';
if (editInput) { editInput = null; }
if (!root) return;
layoutTree();
renderNode(root, canvas, 0);
renderConns(root, canvas);
updateCount();
updateZoomDisplay();
}
function renderNode(node, canvas, depth) {
var el = document.createElement('div');
var cls = 'mm-node';
if (node === root) cls += ' mm-root';
else if (depth >= 1 && depth <= 5) cls += ' mm-depth-' + depth;
else if (depth > 5) cls += ' mm-depth-5';
if (selected && selected.id === node.id) cls += ' mm-selected';
el.className = cls;
el.textContent = node.text;
// 自定义节点颜色
if (node.color) {
el.style.borderColor = node.color;
}
el.style.left = node.x + 'px';
el.style.top = node.y + 'px';
canvas.appendChild(el);
var w = el.offsetWidth;
var h = el.offsetHeight;
el.style.left = (node.x - w / 2) + 'px';
el.style.top = (node.y - h / 2) + 'px';
node.elem = el;
node.w = w;
node.h = h;
for (var i = 0; i < node.children.length; i++) {
renderNode(node.children[i], canvas, depth + 1);
}
}
function renderConns(node, canvas) {
var ch = node.children;
if (!ch.length) return;
var dir = 1; // right
if (node.side === 'left') dir = -1;
else if (node === root) dir = 1; // root 向右画第一批
// root 特殊:左右各画
if (node === root) {
var half = Math.ceil(ch.length / 2);
if (half > 0) drawConns(node, ch.slice(0, half), 1, canvas);
if (ch.length > half) drawConns(node, ch.slice(half), -1, canvas);
} else {
drawConns(node, ch, dir, canvas);
}
for (var i = 0; i < ch.length; i++) {
renderConns(ch[i], canvas);
}
}
function drawConns(parent, children, dir, canvas) {
var pw = parent.w || 100;
var startX = parent.x + dir * (pw / 2);
var midX = parent.x + dir * (H_GAP / 2);
// 主干水平线
mkLine(startX, parent.y, midX, parent.y, canvas);
// 垂直线(连接所有子节点)
if (children.length > 1) {
var minY = Infinity, maxY = -Infinity;
for (var i = 0; i < children.length; i++) {
minY = Math.min(minY, children[i].y);
maxY = Math.max(maxY, children[i].y);
}
mkLine(midX, minY, midX, maxY, canvas);
}
// 分支水平线
for (var i = 0; i < children.length; i++) {
var child = children[i];
var cw = child.w || 100;
var endX = child.x - dir * (cw / 2);
mkLine(midX, child.y, endX, child.y, canvas);
}
}
function mkLine(x1, y1, x2, y2, canvas) {
var el = document.createElement('div');
el.className = 'mm-line';
if (Math.abs(y1 - y2) < 1) {
// 水平线
el.className += ' mm-line-h';
el.style.left = Math.min(x1, x2) + 'px';
el.style.top = (y1 - 1) + 'px';
el.style.width = Math.max(1, Math.abs(x2 - x1)) + 'px';
el.style.height = '2px';
} else {
// 垂直线
el.className += ' mm-line-v';
el.style.left = (x1 - 1) + 'px';
el.style.top = Math.min(y1, y2) + 'px';
el.style.width = '2px';
el.style.height = Math.max(1, Math.abs(y2 - y1)) + 'px';
}
canvas.appendChild(el);
}
// ===== 节点操作 =====
function addChild() {
if (!selected || isEditing) return;
pushHistory();
var n = makeNode('新节点 ' + idCounter);
selected.children.push(n);
selected = n;
render();
}
function addSibling() {
if (!selected || selected === root || isEditing) return;
pushHistory();
var parent = findParent(root, selected.id);
if (!parent) return;
var idx = -1;
for (var i = 0; i < parent.children.length; i++) {
if (parent.children[i].id === selected.id) { idx = i; break; }
}
var n = makeNode('新节点 ' + idCounter);
parent.children.splice(idx + 1, 0, n);
selected = n;
render();
}
function deleteNode() {
if (!selected || selected === root || isEditing) return;
pushHistory();
var parent = findParent(root, selected.id);
if (!parent) return;
var idx = -1;
for (var i = 0; i < parent.children.length; i++) {
if (parent.children[i].id === selected.id) { idx = i; break; }
}
parent.children.splice(idx, 1);
if (parent.children.length > 0) {
selected = parent.children[Math.min(idx, parent.children.length - 1)];
} else {
selected = parent;
}
render();
}
function startEdit() {
if (!selected || isEditing || !selected.elem) return;
isEditing = true;
var node = selected;
var el = node.elem;
el.style.visibility = 'hidden';
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'mm-editor';
inp.value = node.text;
inp.style.left = el.style.left;
inp.style.top = el.style.top;
inp.style.width = Math.max(node.w, 120) + 'px';
var canvas = document.getElementById('mindmap-canvas');
canvas.appendChild(inp);
editInput = inp;
inp.focus();
inp.select();
function finish(save) {
if (save && inp.value.trim()) {
pushHistory();
node.text = inp.value.trim();
}
if (inp.parentNode) inp.parentNode.removeChild(inp);
el.style.visibility = '';
isEditing = false;
editInput = null;
if (save) render();
}
inp.onkeydown = function(e) {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
};
inp.onblur = function() { finish(true); };
}
function findParent(node, id) {
for (var i = 0; i < node.children.length; i++) {
if (node.children[i].id === id) return node;
var f = findParent(node.children[i], id);
if (f) return f;
}
return null;
}
// ===== 导航 =====
function navigate(dir) {
if (!selected) return;
var target = null;
// 处理上下(兄弟导航)
if (SIB[dir]) {
var p = (selected === root) ? null : findParent(root, selected.id);
if (p) {
var idx = -1;
for (var i = 0; i < p.children.length; i++) {
if (p.children[i].id === selected.id) { idx = i; break; }
}
if (SIB[dir] === 'prev' && idx > 0) target = p.children[idx - 1];
if (SIB[dir] === 'next' && idx < p.children.length - 1) target = p.children[idx + 1];
}
} else {
// 处理左右(进出导航)
var d = selected.inDir || IN_R;
var action = d[dir];
if (action === 'firstChild' && selected.children.length > 0) {
target = selected.children[0];
} else if (action === 'parent' && selected !== root) {
target = findParent(root, selected.id);
}
}
if (target) {
selected = target;
render();
scrollToSelected();
}
}
function scrollToSelected() {
if (!selected || !selected.elem) return;
var mc = document.querySelector('.main-content');
var nx = selected.x;
var ny = selected.y;
var vw = mc.clientWidth;
var vh = mc.clientHeight;
mc.scrollLeft = Math.max(0, nx - vw / 2);
mc.scrollTop = Math.max(0, ny - vh / 2);
}
// ===== 事件绑定 =====
function bindEvents() {
var btnMap = {
'btn-new': newFile, 'btn-save': saveFile,
'btn-add-child': addChild, 'btn-add-sibling': addSibling,
'btn-edit': startEdit, 'btn-delete': deleteNode,
'btn-undo': undo, 'btn-redo': redo,
'btn-center': scrollToCenter, 'btn-theme': toggleTheme,
'btn-export-png': exportPNG, 'btn-export-pdf': exportPDF,
'btn-zoom-out': zoomOut, 'btn-zoom-in': zoomIn, 'btn-zoom-reset': zoomReset
};
for (var id in btnMap) {
(function(fn) {
var el = document.getElementById(id);
if (el) el.onclick = fn;
})(btnMap[id]);
}
document.addEventListener('keydown', function(e) {
// 编辑态只处理 Enter/Escape
if (isEditing) {
if (e.key === 'Enter' || e.key === 'Escape') return; // 由 input handler 处理
return; // 其他键不拦截
}
// Tab / Enter
if (!e.ctrlKey && !e.altKey && e.key === 'Tab') { e.preventDefault(); addChild(); return; }
if (!e.ctrlKey && !e.altKey && e.key === 'Enter') { e.preventDefault(); addSibling(); return; }
if (e.key === 'F2') { e.preventDefault(); startEdit(); return; }
if (e.key === 'Delete') { e.preventDefault(); deleteNode(); return; }
// Ctrl 快捷键
if (e.ctrlKey) {
if (e.key === 'z' || e.key === 'Z') { e.preventDefault(); undo(); return; }
if (e.key === 'y' || e.key === 'Y') { e.preventDefault(); redo(); return; }
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); newFile(); return; }
if (e.key === 's' || e.key === 'S') { e.preventDefault(); saveFile(); return; }
// Ctrl + 0/=/- 缩放
if (e.key === '0') { e.preventDefault(); zoomReset(); return; }
if (e.key === '=' || e.key === '+') { e.preventDefault(); zoomIn(); return; }
if (e.key === '-') { e.preventDefault(); zoomOut(); return; }
}
// 方向键导航
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.key) >= 0) {
e.preventDefault();
navigate(e.key);
}
});
// 阻止原生 tooltip(title 属性)触发子窗口崩溃
document.addEventListener('mouseover', function(e) {
var btn = e.target;
if (btn && btn.getAttribute && btn.getAttribute('data-tip')) {
showStatus(btn.getAttribute('data-tip'));
}
});
document.addEventListener('mouseout', function(e) {
var btn = e.target;
if (btn && btn.getAttribute && btn.getAttribute('data-tip')) {
// 不立即清除,让 showStatus 的 3s 定时器自然恢复
}
});
// 获取画布引用
var canvas = document.getElementById('mindmap-canvas');
// 通过事件委托选择节点
canvas.addEventListener('click', function(e) {
if (isEditing) return;
var el = e.target;
while (el && el !== canvas) {
if (el.className && typeof el.className === 'string' && el.className.indexOf('mm-node') >= 0) {
selectByElement(el);
return;
}
el = el.parentNode;
}
});
// 画布拖拽(抓手平移)
var isPanning = false;
var panLastX = 0;
var panLastY = 0;
canvas.addEventListener('mousedown', function(e) {
if (isEditing) return;
// 只在空白区域开始拖拽
if (e.target !== canvas) return;
isPanning = true;
panLastX = e.clientX;
panLastY = e.clientY;
var mc = document.querySelector('.main-content');
if (mc) mc.classList.add('is-grabbing');
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isPanning) return;
var mc = document.querySelector('.main-content');
if (mc) {
mc.scrollLeft -= (e.clientX - panLastX);
mc.scrollTop -= (e.clientY - panLastY);
}
panLastX = e.clientX;
panLastY = e.clientY;
});
document.addEventListener('mouseup', function() {
if (isPanning) {
isPanning = false;
var mc = document.querySelector('.main-content');
if (mc) mc.classList.remove('is-grabbing');
}
});
// 鼠标滚轮缩放(Ctrl + 滚轮)
canvas.addEventListener('wheel', function(e) {
if (!e.ctrlKey) return;
e.preventDefault();
if (e.deltaY < 0) zoomIn();
else zoomOut();
}, { passive: false });
canvas.addEventListener('dblclick', function(e) {
if (isEditing) return;
var el = e.target;
while (el && el !== canvas) {
if (el.className && typeof el.className === 'string' && el.className.indexOf('mm-node') >= 0) {
selectByElement(el);
startEdit();
return;
}
el = el.parentNode;
}
});
// 右键菜单
canvas.addEventListener('contextmenu', function(e) {
e.preventDefault();
var el = e.target;
while (el && el !== canvas) {
if (el.className && typeof el.className === 'string' && el.className.indexOf('mm-node') >= 0) {
selectByElement(el);
showCtxMenu(e.clientX, e.clientY);
return;
}
el = el.parentNode;
}
hideCtxMenu();
});
// 点击其他地方关闭右键菜单
document.addEventListener('mousedown', function(e) {
var menu = document.getElementById('ctx-menu');
if (menu && !menu.contains(e.target)) {
hideCtxMenu();
}
});
// 右键菜单颜色选择(用 mousedown 避免 document mousedown 先关闭菜单导致 click 失效)
var menu = document.getElementById('ctx-menu');
if (menu) {
menu.addEventListener('mousedown', function(e) {
var item = e.target;
if (item.className && item.className.indexOf('ctx-color') >= 0 && item.getAttribute) {
e.preventDefault();
e.stopPropagation();
var color = item.getAttribute('data-color');
if (selected) {
pushHistory();
selected.color = color || '';
render();
showStatus(color ? '已设置节点颜色' : '已恢复默认颜色');
}
hideCtxMenu();
}
});
// 右键菜单点击
menu.addEventListener('click', function(e) {
var item = e.target;
while (item && item !== menu) {
if (item.getAttribute && item.getAttribute('data-action')) {
var action = item.getAttribute('data-action');
hideCtxMenu();
if (action === 'add-child') addChild();
else if (action === 'add-sibling') addSibling();
else if (action === 'edit') startEdit();
else if (action === 'delete') deleteNode();
return;
}
item = item.parentNode;
}
});
}
}
function selectByElement(el) {
// 遍历树找对应节点(通过 elem 引用)
var found = findByElem(root, el);
if (found) {
// 仅更新选中态 CSS,不重新渲染(避免 DOM 重建导致引用错乱)
if (selected && selected.elem) {
selected.elem.className = selected.elem.className.replace(' mm-selected', '');
}
selected = found;
if (selected.elem) {
selected.elem.className += ' mm-selected';
}
}
}
function findByElem(node, el) {
if (node.elem === el) return node;
for (var i = 0; i < node.children.length; i++) {
var f = findByElem(node.children[i], el);
if (f) return f;
}
return null;
}
// ===== 右键菜单 =====
function showCtxMenu(x, y) {
var menu = document.getElementById('ctx-menu');
if (!menu) return;
menu.style.display = 'block';
// 确保菜单不超出屏幕
var mw = menu.offsetWidth || 180;
var mh = menu.offsetHeight || 150;
if (x + mw > window.innerWidth) x = window.innerWidth - mw - 4;
if (y + mh > window.innerHeight) y = window.innerHeight - mh - 4;
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// 根节点不能删除,隐藏删除项
var items = menu.querySelectorAll('.ctx-danger');
for (var i = 0; i < items.length; i++) {
items[i].style.display = (selected === root) ? 'none' : '';
}
// 根节点不显示“添加兄弟”
var siblings = menu.querySelectorAll('[data-action="add-sibling"]');
for (var i = 0; i < siblings.length; i++) {
siblings[i].style.display = (selected === root) ? 'none' : '';
}
// 高亮当前节点颜色
var colors = menu.querySelectorAll('.ctx-color');
var curColor = (selected && selected.color) ? selected.color : '';
for (var i = 0; i < colors.length; i++) {
var c = colors[i].getAttribute('data-color') || '';
if (c === curColor) {
colors[i].className = 'ctx-color ctx-color-active';
} else {
colors[i].className = 'ctx-color';
}
}
}
function hideCtxMenu() {
var menu = document.getElementById('ctx-menu');
if (menu) menu.style.display = 'none';
}
// ===== 缩放功能 =====
function setZoom(level) {
zoomLevel = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(level * 10) / 10));
var canvas = document.getElementById('mindmap-canvas');
if (canvas) {
canvas.style.transform = 'scale(' + zoomLevel + ')';
canvas.style.transformOrigin = '0 0';
}
updateZoomDisplay();
}
function zoomIn() {
setZoom(zoomLevel + ZOOM_STEP);
showStatus('缩放:' + Math.round(zoomLevel * 100) + '%');
}
function zoomOut() {
setZoom(zoomLevel - ZOOM_STEP);
showStatus('缩放:' + Math.round(zoomLevel * 100) + '%');
}
function zoomReset() {
setZoom(1);
showStatus('已重置缩放');
}
function updateZoomDisplay() {
var el = document.getElementById('zoom-display');
if (el) el.textContent = Math.round(zoomLevel * 100) + '%';
}
// ===== 工具 =====
function showStatus(text) {
var el = document.getElementById('status-text');
if (el) el.textContent = text;
clearTimeout(showStatus._t);
showStatus._t = setTimeout(function() {
if (el) el.textContent = '就绪 - Tab 添加子节点 | Enter 添加兄弟 | F2 编辑 | Delete 删除';
}, 3000);
}
function updateCount() {
var el = document.getElementById('node-count');
if (el) el.textContent = countNodes(root) + ' 个节点';
}
// ===== 文件操作 =====
function newFile() {
pushHistory();
idCounter = 1;
root = makeNode('中心主题');
selected = root;
render();
scrollToCenter();
showStatus('已新建');
}
function scrollToCenter() {
requestAnimationFrame(function() {
var mc = document.querySelector('.main-content');
if (mc) {
mc.scrollLeft = canvasCX - mc.clientWidth / 2;
mc.scrollTop = canvasCY - mc.clientHeight / 2;
}
});
}
async function saveFile() {
try {
var filePath = await ipcRenderer.invoke('auto-save-path');
if (!filePath) {
showStatus('保存失败:无法获取路径');
return;
}
var ok = await ipcRenderer.invoke('write-file', filePath, { root: root, idCounter: idCounter });
showStatus(ok ? '已自动保存' : '保存失败');
} catch (err) {
showStatus('保存失败');
}
}
// ===== 导出功能 =====
// roundRect 兼容(部分 ArkWeb 版本不支持)
function canvasRoundRect(ctx, x, y, w, h, r) {
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
return;
}
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
var DEPTH_COLORS = ['#107c10', '#008575', '#8764b8', '#ca5010', '#e3008c'];
function getNodeColors(node, depth) {
if (node.color) return { bg: '#ffffff', border: node.color, text: '#333333' };
if (node === root) return { bg: '#0078d4', border: '#005a9e', text: '#ffffff' };
if (depth >= 1 && depth <= 5) return { bg: '#ffffff', border: DEPTH_COLORS[depth - 1], text: '#333333' };
if (depth > 5) return { bg: '#ffffff', border: DEPTH_COLORS[4], text: '#333333' };
return { bg: '#ffffff', border: '#0078d4', text: '#333333' };
}
// 生成离屏 Canvas(导出 PNG/PDF 共用)
function buildExportCanvas(scale) {
scale = scale || 2;
var nodes = [];
var conns = [];
function walk(node, depth, parentId) {
var w = node.w || 100;
var h = node.h || 40;
nodes.push({ x: node.x, y: node.y, w: w, h: h, text: node.text,
colors: getNodeColors(node, depth), isRoot: node === root, depth: depth });
for (var i = 0; i < node.children.length; i++) {
var child = node.children[i];
var dir = (child.side === 'left') ? -1 : 1;
var cw = child.w || 100;
conns.push({
sx: node.x + dir * (w / 2), sy: node.y,
mx: node.x + dir * (H_GAP / 2),
ex: child.x - dir * (cw / 2), ey: child.y
});
walk(child, depth + 1, node.id);
}
}
walk(root, 0, null);
// 边界计算
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
minX = Math.min(minX, n.x - n.w / 2);
minY = Math.min(minY, n.y - n.h / 2);
maxX = Math.max(maxX, n.x + n.w / 2);
maxY = Math.max(maxY, n.y + n.h / 2);
}
var pad = 60;
var offX = minX - pad;
var offY = minY - pad;
var W = (maxX - minX) + pad * 2;
var H = (maxY - minY) + pad * 2;
// 动态缩放:防止 Canvas 超出 ArkWeb 最大像素限制(约 16M 像素)
var MAX_AREA = 4096 * 4096;
var area = W * H * scale * scale;
if (area > MAX_AREA) {
scale = Math.sqrt(MAX_AREA / (W * H));
}
var canvas = document.createElement('canvas');
canvas.width = Math.round(W * scale);
canvas.height = Math.round(H * scale);
var ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// 背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
// 连接线
ctx.strokeStyle = '#b0b8c4';
ctx.lineWidth = 2;
ctx.lineCap = 'butt';
for (var i = 0; i < conns.length; i++) {
var c = conns[i];
var sx = c.sx - offX, sy = c.sy - offY;
var mx = c.mx - offX;
var ex = c.ex - offX, ey = c.ey - offY;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(mx, sy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mx, sy); ctx.lineTo(mx, ey); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mx, ey); ctx.lineTo(ex, ey); ctx.stroke();
}
// 节点
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var nx = n.x - n.w / 2 - offX;
var ny = n.y - n.h / 2 - offY;
var r = n.isRoot ? 24 : 20;
// 阴影
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 8;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 2;
canvasRoundRect(ctx, nx, ny, n.w, n.h, r);
ctx.fillStyle = n.colors.bg;
ctx.fill();
ctx.restore();
// 边框
canvasRoundRect(ctx, nx, ny, n.w, n.h, r);
ctx.strokeStyle = n.colors.border;
ctx.lineWidth = 2;
ctx.stroke();
// 文字
ctx.fillStyle = n.colors.text;
ctx.font = (n.isRoot ? 'bold 16px' : '14px') + ' sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
var label = n.text.length > 20 ? n.text.substring(0, 18) + '..' : n.text;
ctx.fillText(label, nx + n.w / 2, ny + n.h / 2);
}
return canvas;
}
async function exportPNG() {
try {
var canvas = buildExportCanvas(2);
var dataUrl = canvas.toDataURL('image/png');
var base64 = dataUrl.split(',')[1];
if (!base64 || base64.length < 100) {
showStatus('PNG 导出失败:图片数据异常');
return;
}
var filePath = await ipcRenderer.invoke('export-png-path');
if (!filePath) {
showStatus('已取消导出');
return;
}
var ok = await ipcRenderer.invoke('write-binary', filePath, base64);
showStatus(ok ? '已导出:' + filePath : 'PNG 导出失败');
} catch (err) {
showStatus('PNG 导出失败');
}
}
async function exportPDF() {
try {
var canvas = buildExportCanvas(2);
var dataUrl = canvas.toDataURL('image/png');
var html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>\u601d\u7ef4\u5bfc\u56fe</title>' +
'<style>@page{margin:10mm}body{margin:0;text-align:center}' +
'img{max-width:100%;max-height:100%;page-break-inside:avoid}</style></head>' +
'<body><img src="' + dataUrl + '">' +
'<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print()},300)}<\/scr' + 'ipt>' +
'</body></html>';
var printWin = window.open('', '_blank');
if (printWin) {
printWin.document.write(html);
printWin.document.close();
showStatus('正在准备 PDF 打印...');
} else {
// window.open 被拦截,降级为 IPC 写 PNG
var base64 = dataUrl.split(',')[1];
var filePath = await ipcRenderer.invoke('export-png-path');
if (filePath && base64) {
var ok = await ipcRenderer.invoke('write-binary', filePath, base64);
showStatus(ok ? '无法打印,已导出 PNG 代替' : 'PDF 导出失败');
} else {
showStatus('PDF 导出失败');
}
}
} catch (err) {
showStatus('PDF 导出失败');
}
}
// ===== 主题切换 =====
var currentTheme = 'light';
function toggleTheme() {
currentTheme = (currentTheme === 'light') ? 'dark' : 'light';
applyTheme();
try { localStorage.setItem('freeplane-theme', currentTheme); } catch(e) {}
showStatus(currentTheme === 'dark' ? '已切换暗色主题' : '已切换亮色主题');
}
function applyTheme() {
var body = document.body;
var btn = document.getElementById('btn-theme');
if (currentTheme === 'dark') {
body.className = 'theme-dark';
if (btn) btn.textContent = '☾';
} else {
body.className = '';
if (btn) btn.textContent = '☀';
}
}
function loadTheme() {
try {
var saved = localStorage.getItem('freeplane-theme');
if (saved === 'dark') currentTheme = 'dark';
} catch(e) {}
applyTheme();
}
// ===== 工具栏颜色色板 =====
var lastPickedColor = '';
function toggleColorPalette() {
var palette = document.getElementById('color-palette');
if (!palette) return;
var visible = palette.style.display === 'block';
if (visible) {
palette.style.display = 'none';
} else {
// 定位在按钮下方
var btn = document.getElementById('btn-color');
if (btn) {
var rect = btn.getBoundingClientRect();
palette.style.left = (rect.right - 200) + 'px';
palette.style.top = (rect.bottom + 4) + 'px';
}
palette.style.display = 'block';
// 高亮当前颜色
updatePaletteHighlight();
}
}
function updatePaletteHighlight() {
var palette = document.getElementById('color-palette');
if (!palette) return;
var curColor = (selected && selected.color) ? selected.color : '';
var dots = palette.querySelectorAll('.cp-dot');
for (var i = 0; i < dots.length; i++) {
var c = dots[i].getAttribute('data-color') || '';
dots[i].className = (c === curColor) ? 'cp-dot cp-dot-active' : 'cp-dot';
}
}
function applyColorToSelected(color) {
if (!selected) return;
pushHistory();
selected.color = color || '';
lastPickedColor = color || '';
render();
showStatus(color ? '已设置节点颜色' : '已恢复默认颜色');
}
function applyColorToAll(color) {
pushHistory();
setAllNodeColors(root, color || '');
lastPickedColor = color || '';
render();
showStatus(color ? '已将颜色应用到所有节点' : '已清除所有节点颜色');
}
function setAllNodeColors(node, color) {
node.color = color;
for (var i = 0; i < node.children.length; i++) {
setAllNodeColors(node.children[i], color);
}
}
function bindColorPalette() {
var btn = document.getElementById('btn-color');
if (btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
toggleColorPalette();
});
}
var palette = document.getElementById('color-palette');
if (palette) {
// 色点点击(用 mousedown 确保可靠触发)
palette.addEventListener('mousedown', function(e) {
var item = e.target;
if (item.className && item.className.indexOf('cp-dot') >= 0) {
e.preventDefault();
e.stopPropagation();
var color = item.getAttribute('data-color');
applyColorToSelected(color);
updatePaletteHighlight();
}
});
// 阻止色板内部点击冒泡到 document
palette.addEventListener('click', function(e) {
e.stopPropagation();
});
}
// 全部应用按钮
var btnAll = document.getElementById('btn-color-all');
if (btnAll) {
btnAll.addEventListener('click', function(e) {
e.stopPropagation();
var color = lastPickedColor;
if (!color && selected && selected.color) color = selected.color;
applyColorToAll(color);
hideColorPalette();
});
}
// 点击其他地方关闭色板
document.addEventListener('mousedown', function(e) {
var palette = document.getElementById('color-palette');
var btn = document.getElementById('btn-color');
if (palette && palette.style.display === 'block') {
if (!palette.contains(e.target) && e.target !== btn) {
hideColorPalette();
}
}
});
}
function hideColorPalette() {
var palette = document.getElementById('color-palette');
if (palette) palette.style.display = 'none';
}
// ===== 默认模板 =====
function createDefault() {
idCounter = 1;
root = makeNode('我的思维导图');
// 右侧分支
var n1 = makeNode('工作计划');
n1.children = [
makeNode('本周目标'),
makeNode('项目进展'),
makeNode('资源协调')
];
n1.children[0].children = [
makeNode('完成需求评审'),
makeNode('提交测试版本')
];
n1.children[1].children = [
makeNode('模块 A 已上线'),
makeNode('模块 B 开发中')
];
var n2 = makeNode('学习笔记');
n2.children = [
makeNode('鸿蒙开发'),
makeNode('Electron 框架'),
makeNode('性能优化')
];
n2.children[0].children = [
makeNode('ArkTS 语法'),
makeNode('ArkUI 组件')
];
// 左侧分支
var n3 = makeNode('生活事项');
n3.children = [
makeNode('健身计划'),
makeNode('读书清单')
];
n3.children[1].children = [
makeNode('《深入理解计算机系统》'),
makeNode('《设计模式》')
];
var n4 = makeNode('创意灵感');
n4.children = [
makeNode('产品想法'),
makeNode('技术方案')
];
root.children = [n1, n2, n3, n4];
selected = root;
}
// ===== 初始化 =====
document.addEventListener('DOMContentLoaded', function() {
bindEvents();
bindColorPalette();
loadTheme();
createDefault();
render();
scrollToCenter();
});

关键要点:
- 纯 DOM 渲染:使用 position: absolute 的 div 元素,零 SVG、零 Canvas、零外部库
- 自研布局算法:递归树形布局,左右分支对称分布,动态计算画布尺寸
- 事件绑定:btnMap 对象映射 + for…in 遍历绑定,代码更精简
- 缩放系统:支持 30%~300% 缩放范围,transform: scale(zoomLevel) 实现
- 撤销/重做:100 步历史栈,JSON.stringify/parse 深拷贝
- 键盘导航:方向键遍历节点树,Tab/Enter 快速添加,F2 编辑,Delete 删除
- 画布拖拽:鼠标拖拽平移画布,抓手光标交互(抓手/抓取光标切换)
- 离屏 Canvas 导出:buildExportCanvas(2) 生成 2x 分辨率 PNG,支持 roundRect 兼容
3.5 第五步:编写样式文件(freeplane.css)
文件:web_engine/src/main/resources/resfile/resources/app/styles/freeplane.css
/* FreePlane Mind Map - 主样式文件 */
/* 鸿蒙 ArkWeb 兼容:不使用 CSS 变量 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
background: #f0f2f5;
color: #333;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ===== 标题栏 ===== */
.title-bar {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0078d4, #005a9e);
color: #fff;
font-size: 15px;
font-weight: 600;
letter-spacing: 1px;
user-select: none;
}
.logo {
margin-right: 8px;
font-size: 14px;
}
.app-title {
font-size: 15px;
}
/* ===== 操作栏 ===== */
.toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
user-select: none;
}
.btn-group {
display: flex;
gap: 2px;
}
.sep {
width: 1px;
height: 24px;
background: #e0e0e0;
margin: 0 4px;
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #555;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.btn:hover {
background: #e8f0fe;
color: #0078d4;
border-color: #d0e0f0;
}
.btn:active {
background: #d0e0f0;
}
.btn-danger {
color: #d83b01;
}
.btn-danger:hover {
background: #fde7e0;
color: #c12b00;
border-color: #f5c6b5;
}
/* ===== 主内容区 ===== */
.main-content {
flex: 1;
overflow: auto;
position: relative;
background: #f7f8fa;
cursor: grab;
}
.main-content.is-grabbing {
cursor: grabbing;
}
/* 思维导图画布 */
#mindmap-canvas {
position: relative;
min-width: 100%;
min-height: 100%;
}
/* ===== 节点样式 ===== */
.mm-node {
position: absolute;
padding: 8px 18px;
border-radius: 20px;
background: #fff;
border: 2px solid #0078d4;
color: #333;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
user-select: none;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.mm-node:hover {
box-shadow: 0 3px 10px rgba(0,0,0,0.14);
border-color: #005a9e;
}
/* 选中态 */
.mm-selected {
border-color: #d83b01 !important;
border-width: 3px;
box-shadow: 0 2px 8px rgba(216,59,1,0.25);
}
/* 根节点 */
.mm-root {
background: #0078d4;
color: #fff;
font-size: 16px;
font-weight: 700;
border-color: #005a9e;
padding: 10px 28px;
border-radius: 24px;
box-shadow: 0 3px 12px rgba(0,120,212,0.3);
}
.mm-root:hover {
box-shadow: 0 4px 16px rgba(0,120,212,0.4);
}
/* 层级颜色(depth 1-5) */
.mm-depth-1 { border-color: #107c10; }
.mm-depth-2 { border-color: #008575; }
.mm-depth-3 { border-color: #8764b8; }
.mm-depth-4 { border-color: #ca5010; }
.mm-depth-5 { border-color: #e3008c; }
/* ===== 连接线 ===== */
.mm-line {
position: absolute;
background: #b0b8c4;
border-radius: 1px;
}
.mm-line-h { height: 2px; }
.mm-line-v { width: 2px; }
/* ===== 编辑输入框 ===== */
.mm-editor {
position: absolute;
padding: 6px 16px;
border: 2px solid #0078d4;
border-radius: 20px;
font-size: 14px;
outline: none;
min-width: 100px;
max-width: 220px;
background: #fff;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
/* ===== 底部状态栏 ===== */
.status-bar {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #fff;
border-top: 1px solid #e8e8e8;
font-size: 12px;
color: #888;
}
#node-count {
color: #0078d4;
font-weight: 500;
}
/* ===== 右键菜单 ===== */
.ctx-menu {
display: none;
position: fixed;
z-index: 9999;
background: #fff;
border: 1px solid #d0d0d0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
padding: 4px 0;
min-width: 180px;
}
.ctx-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
font-size: 14px;
color: #333;
cursor: pointer;
}
.ctx-item:hover {
background: #e8f0fe;
color: #0078d4;
}
.ctx-danger {
color: #d83b01;
}
.ctx-danger:hover {
background: #fde7e0;
color: #c12b00;
}
.ctx-sep {
height: 1px;
background: #e8e8e8;
margin: 4px 8px;
}
.ctx-hint {
font-size: 11px;
color: #aaa;
margin-left: 16px;
}
/* ===== 颜色色板 ===== */
.ctx-colors-label {
padding: 6px 16px 2px;
font-size: 11px;
color: #999;
}
.ctx-colors {
display: flex;
gap: 6px;
padding: 4px 16px 8px;
flex-wrap: wrap;
}
.ctx-color {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #ddd;
cursor: pointer;
background: #fff;
}
.ctx-color:hover {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.25);
}
.ctx-color-active {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.4);
}
/* ===== 工具栏颜色色板 ===== */
.btn-color-trigger {
color: #e74c3c;
font-size: 16px;
}
.color-palette {
display: none;
position: fixed;
z-index: 9998;
background: #fff;
border: 1px solid #d0d0d0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
padding: 8px;
width: 210px;
}
.color-palette-title {
font-size: 11px;
color: #999;
padding: 0 4px 4px;
}
.color-palette-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding: 0 4px;
}
.cp-dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #ddd;
cursor: pointer;
}
.cp-dot:hover {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.25);
}
.cp-dot-active {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.4);
}
.color-palette-actions {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #eee;
}
.cp-apply-all {
width: 100%;
height: 28px;
font-size: 12px;
background: #f0f2f5;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
color: #555;
}
.cp-apply-all:hover {
background: #e8f0fe;
color: #0078d4;
border-color: #d0e0f0;
}
/* ===== 缩放控件 ===== */
.zoom-display {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 32px;
padding: 0 8px;
font-size: 13px;
font-weight: 600;
color: #0078d4;
background: #f0f7ff;
border-radius: 4px;
user-select: none;
}
/* ===== 暗色主题 ===== */
.theme-dark body,
body.theme-dark {
background: #1e1e1e;
color: #d4d4d4;
}
.theme-dark .title-bar {
background: linear-gradient(135deg, #2b5797, #1a3a6a);
}
.theme-dark .toolbar {
background: #252526;
border-bottom-color: #3c3c3c;
}
.theme-dark .btn {
color: #b0b0b0;
}
.theme-dark .btn:hover {
background: #37373d;
color: #6cb6ff;
border-color: #4a4a50;
}
.theme-dark .btn:active {
background: #4a4a50;
}
.theme-dark .btn-danger {
color: #f48771;
}
.theme-dark .btn-danger:hover {
background: #3d2020;
color: #ff9980;
border-color: #5a3030;
}
.theme-dark .sep {
background: #3c3c3c;
}
.theme-dark .main-content {
background: #1e1e1e;
}
.theme-dark .mm-node {
background: #2d2d2d;
border-color: #569cd6;
color: #d4d4d4;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.theme-dark .mm-node:hover {
box-shadow: 0 3px 10px rgba(0,0,0,0.5);
border-color: #79b8ff;
}
.theme-dark .mm-selected {
border-color: #ce9178 !important;
box-shadow: 0 2px 8px rgba(206,145,120,0.35);
}
.theme-dark .mm-root {
background: #2b5797;
color: #fff;
border-color: #3b6db5;
box-shadow: 0 3px 12px rgba(43,87,151,0.4);
}
.theme-dark .mm-root:hover {
box-shadow: 0 4px 16px rgba(43,87,151,0.6);
}
.theme-dark .mm-depth-1 { border-color: #4ec9b0; }
.theme-dark .mm-depth-2 { border-color: #569cd6; }
.theme-dark .mm-depth-3 { border-color: #c586c0; }
.theme-dark .mm-depth-4 { border-color: #dcdcaa; }
.theme-dark .mm-depth-5 { border-color: #ce9178; }
.theme-dark .mm-line {
background: #555;
}
.theme-dark .mm-editor {
background: #2d2d2d;
border-color: #569cd6;
color: #d4d4d4;
box-shadow: 0 2px 8px rgba(86,156,214,0.3);
}
.theme-dark .status-bar {
background: #252526;
border-top-color: #3c3c3c;
color: #888;
}
.theme-dark #node-count {
color: #569cd6;
}
.theme-dark .ctx-menu {
background: #2d2d2d;
border-color: #454545;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.theme-dark .ctx-item {
color: #d4d4d4;
}
.theme-dark .ctx-item:hover {
background: #37373d;
color: #6cb6ff;
}
.theme-dark .ctx-danger {
color: #f48771;
}
.theme-dark .ctx-danger:hover {
background: #3d2020;
color: #ff9980;
}
.theme-dark .ctx-sep {
background: #3c3c3c;
}
.theme-dark .ctx-hint {
color: #666;
}
.theme-dark .ctx-colors-label {
color: #666;
}
.theme-dark .ctx-color {
border-color: #555;
}
.theme-dark .ctx-color:hover {
border-color: #6cb6ff;
box-shadow: 0 0 0 2px rgba(108,182,255,0.3);
}
.theme-dark .ctx-color-active {
border-color: #6cb6ff;
box-shadow: 0 0 0 2px rgba(108,182,255,0.4);
}
.theme-dark .color-palette {
background: #2d2d2d;
border-color: #454545;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.theme-dark .color-palette-title {
color: #666;
}
.theme-dark .cp-dot {
border-color: #555;
}
.theme-dark .cp-dot:hover {
border-color: #6cb6ff;
box-shadow: 0 0 0 2px rgba(108,182,255,0.3);
}
.theme-dark .cp-dot-active {
border-color: #6cb6ff;
box-shadow: 0 0 0 2px rgba(108,182,255,0.4);
}
.theme-dark .color-palette-actions {
border-top-color: #3c3c3c;
}
.theme-dark .cp-apply-all {
background: #3c3c3c;
border-color: #555;
color: #b0b0b0;
}
.theme-dark .cp-apply-all:hover {
background: #4a4a50;
color: #6cb6ff;
border-color: #4a4a50;
}
.theme-dark .zoom-display {
color: #6cb6ff;
background: #2a2a30;
}

关键要点:
- 浅色主题设计:使用 #ffffff、#f0f2f5 等亮色系,专业思维导图风格
- 三栏布局:标题栏 + 操作栏 + 画布容器(100% 自适应)+ 底部状态栏
- 节点样式:圆角矩形(border-radius: 20px),轻微阴影,悬停高亮
- 层级颜色:depth 1-5 自动着色(绿/青/紫/橙/粉),视觉层次清晰
- 根节点特殊样式:蓝色背景 + 白色文字 + 更大字号 + 更粗边框
- 缩放控件:蓝色高亮显示当前缩放比例
- 暗色主题:70+ 个选择器完整覆盖,包含:
- 按钮状态(hover/active/danger)
- 节点样式(选中/根节点/层级颜色/编辑器)
- 右键菜单(7 个子选择器)
- 颜色色板(9 个子选择器)
- 分隔线、提示文字等细节
- 鸿蒙 ArkWeb 兼容:不使用 CSS 变量(var(–xxx)),直接写实际颜色值
- 移除 Webkit 滚动条样式:鸿蒙不支持 ::-webkit-scrollbar,已删除
四、部署到鸿蒙平台
4.1 项目结构说明
开发****工作流:
- 直接在 electron-apps/freeplane/ 中修改代码
- 同步到 web_engine/src/main/resources/resfile/resources/app/
- 在 DevEco Studio 中构建并运行
- 真机测试验证
4.2 构建 HAP 包
在 DevEco Studio 中:
- 打开项目根目录 ohos_hap/
- 点击 Build > Build Hap(s)/APP(s)
- 选择 Build Hap(s)
- 等待构建完成

4.3 真机测试
- 连接鸿蒙设备(或启动模拟器)
- 点击 Run > Run ‘entry’
- 安装完成后,应用会自动启动






五、常见问题 FAQ
Q1:点击导出 PNG 后应用闪退?
问题现象:点击导出 PNG 按钮后应用崩溃
根本原因:ArkWeb 中任何创建新窗口的 API 都会触发 XComponent 崩溃(dialog.showSaveDialog → CreateSubWindow → WaitForXComponentCreated 超时 → abort)
解决方案:
// ✅ 当前方案:原生保存对话框(真机已验证稳定)
ipcMain.handle('export-png-path', async () => {
var result = await dialog.showSaveDialog(mainWindow, {
title: '导出 PNG 图片',
defaultPath: path.join(saveDir, 'mindmap.png'),
filters: [{ name: 'PNG 图片', extensions: ['png'] }]
});
if (result.canceled || !result.filePath) return null;
return result.filePath;
});
关键点:
- 真机测试验证 dialog.showSaveDialog 稳定
- 配合 ipcRenderer.invoke(‘write-binary’) 实现二进制写入
- Buffer.from(base64, ‘base64’) 转换 base64 为 Buffer
Q2:鼠标悬停在按钮上闪退?
问题现象:鼠标悬停在节点颜色按钮上触发 SIGABRT 崩溃
根本原因:title 属性在 ArkWeb 中触发系统级原生 tooltip 弹窗 → 创建 SubWindow → XComponent 崩溃
解决方案:
<!-- ❌ 错误:使用 title 属性 -->
<button title="节点颜色">●</button>
<!-- ✅ 正确:使用 data-tip 自定义属性 -->
<button data-tip="节点颜色">●</button>
配合 renderer.js:
// 阻止原生 tooltip(title 属性)触发子窗口崩溃
document.addEventListener('mouseover', function(e) {
var btn = e.target;
if (btn && btn.getAttribute && btn.getAttribute('data-tip')) {
showStatus(btn.getAttribute('data-tip'));
}
});
关键点:
- data-tip 是纯自定义属性,不触发系统行为
- mouseover 事件 → 状态栏显示提示文本
- 3 秒后自动恢复默认提示
Q3:导出 PNG 图片损坏或空白?
问题现象:导出的 PNG 文件无法打开或显示空白
根本原因:canvas.toDataURL() 在 ArkWeb 中可能截断 base64 字符串
解决方案:
async function exportPNG() {
try {
// 使用离屏 Canvas 渲染(不依赖 html2canvas)
var canvas = buildExportCanvas(2);
var dataUrl = canvas.toDataURL('image/png');
var base64 = dataUrl.split(',')[1];
// 验证 base64 数据完整性
if (!base64 || base64.length < 100) {
showStatus('PNG 导出失败:图片数据异常');
return;
}
// IPC 写文件
var filePath = await ipcRenderer.invoke('export-png-path');
var ok = await ipcRenderer.invoke('write-binary', filePath, base64);
showStatus(ok ? '已导出:' + filePath : 'PNG 导出失败');
} catch (err) {
showStatus('PNG 导出失败');
}
}
关键点:
- 使用 buildExportCanvas(2) 自研离屏 Canvas 渲染(2x 分辨率)
- 不依赖 html2canvas 等外部库
- 验证 base64 长度(base64.length < 100 检测截断)
- Buffer.from(base64, ‘base64’) 确保二进制完整性
Q4:鸿蒙平台 CSS 样式不生效?
问题现象:部分 CSS 样式在鸿蒙设备上未显示
根本原因:鸿蒙 ArkWeb 不支持 CSS 自定义属性(变量)
解决方案:
/* ❌ 错误:使用 CSS 变量 */
.toolbar {
background: var(--toolbar-bg);
}
/* ✅ 正确:使用实际值 */
.toolbar {
background: #ffffff; /* 白色背景 */
}
/* ❌ 错误:使用 CSS 变量 */
.btn:hover {
background: var(--hover-color);
}
/* ✅ 正确:使用实际值 */
.btn:hover {
background: #e8f0fe; /* 悬停高亮 */
}
关键点:
- 将所有 CSS 变量替换为实际值
- ArkWeb 不支持 var(–xxx) 自定义属性
- 颜色值直接使用十六进制或 rgba
- 其他 CSS 特性(flex、transition、transform)均支持
Q5:节点布局错乱或重叠?
问题现象:节点位置不正确,出现重叠或间距异常
根本原因:布局算法未正确计算子树高度
解决方案:
function calcHeight(nodes) {
var total = 0;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (!n.children.length) {
total += V_GAP;
} else {
total += calcHeight(n.children);
}
}
return Math.max(total, V_GAP);
}
function layoutSub(node, x, y, side) {
node.x = x;
node.y = y;
node.side = side;
if (node.children.length === 0) return;
var childHeight = calcHeight(node.children);
var cy = y - childHeight / 2;
for (var i = 0; i < node.children.length; i++) {
var h = calcHeight([node.children[i]]);
layoutSub(node.children[i], x + (side === 'right' ? H_GAP : -H_GAP), cy + h / 2, side);
cy += h;
}
}
关键点:
- 递归计算子树高度
- 左右分支对称分布
- 动态调整画布尺寸(根据最大深度和节点总数)
- 水平间距 H_GAP = 250,垂直间距 V_GAP = 50
Q6:保存文件后无法再次打开?
问题现象:保存的 .json 文件无法再次加载
根本原因:未正确序列化 JSON 或未包含完整节点数据
解决方案:
async function saveFile() {
try {
var filePath = await ipcRenderer.invoke('auto-save-path');
if (!filePath) {
showStatus('保存失败:无法获取路径');
return;
}
var ok = await ipcRenderer.invoke('write-file', filePath, {
root: root,
idCounter: idCounter
});
showStatus(ok ? '已自动保存' : '保存失败');
} catch (err) {
showStatus('保存失败');
}
}
关键点:
- 使用 ipcRenderer.invoke(‘auto-save-path’) 获取自动保存路径
- 保存完整节点树(root)和 ID 计数器(idCounter)
- 主进程使用 JSON.stringify(data, null, 2) 格式化输出
- 主进程使用 utf8 编码写入
Q7:鸿蒙平台构建失败或文件未加载?
问题现象:hvigor 构建时报错,或应用启动后白屏
根本原因:文件未正确放置在 resfile 目录或同步不完整
解决方案:
- 确认文件结构正确:
web_engine/src/main/resources/resfile/resources/app/
├── main.js
├── renderer.js
├── index.html
└── styles/
└── freeplane.css
- 从 electron-apps 同步到 web_engine:
# 在 PowerShell 中执行
Copy-Item -Path "electron-apps\freeplane\main.js" -Destination "web_engine\src\main\resources\resfile\resources\app\main.js" -Force
Copy-Item -Path "electron-apps\freeplane\renderer.js" -Destination "web_engine\src\main\resources\resfile\resources\app\renderer.js" -Force
Copy-Item -Path "electron-apps\freeplane\index.html" -Destination "web_engine\src\main\resources\resfile\resources\app\index.html" -Force
Copy-Item -Path "electron-apps\freeplane\styles\freeplane.css" -Destination "web_engine\src\main\resources\resfile\resources\app\styles\freeplane.css" -Force
- 验证文件加载:
// 在 Index.ets 中添加日志
Web({ src: $rawfile('resources/app/index.html') })
.onPageBegin((event) => {
console.info('WebView 开始加载:', event.url);
})
.onPageEnd((event) => {
console.info('WebView 加载完成:', event.url);
})
.onErrorReceive((event) => {
console.error('WebView 加载失败:', JSON.stringify(event));
})
注意事项:
- resfile 目录下的文件使用 $rawfile() 加载
- 确保所有文件路径正确,无拼写错误
- 真机测试时检查 DevEco Studio 控制台日志
- 每次修改后必须重新同步并构建
Q8:为什么 FreePlane 比 Excalidraw 适配更轻量?
问题现象:FreePlane 几乎不需要外部依赖就能运行
根本原因:纯 DOM 实现,零 SVG、零 Canvas、零外部库
技术解析:
FreePlane 技术栈:
├── 纯 DOM 渲染 ← position: absolute 的 div 元素
├── 自研布局算法 ← 递归树形布局,左右对称分布
├── CSS 样式 ← 直接写实际值,不使用 CSS 变量
└── JavaScript ← 通用脚本语言
关键点:
- 不使用 SVG/Canvas 等图形 API,仅使用 div 元素和 CSS
- 自研递归树形布局算法,动态计算画布尺寸
- 鸿蒙 ArkWeb 基于 Chromium,完整支持现代 Web 标准
- 只处理了 CSS 变量和滚动条样式的兼容问题
- 对比 Excalidraw:无需处理 React/Canvas/SVG 等复杂依赖
核心优势:
- 纯 DOM 实现:零外部依赖,仅 4 个核心文件
- 快速开发:Web 技术栈,开发效率高
- 易于维护:UI 和业务逻辑分离
- 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
更多推荐



所有评论(0)