项目简介

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 项目结构说明

开发****工作流

  1. 直接在 electron-apps/freeplane/ 中修改代码
  2. 同步到 web_engine/src/main/resources/resfile/resources/app/
  3. 在 DevEco Studio 中构建并运行
  4. 真机测试验证

4.2 构建 HAP 包

在 DevEco Studio 中:

  1. 打开项目根目录 ohos_hap/
  2. 点击 Build > Build Hap(s)/APP(s)
  3. 选择 Build Hap(s)
  4. 等待构建完成

4.3 真机测试

  1. 连接鸿蒙设备(或启动模拟器)
  2. 点击 Run > Run ‘entry’
  3. 安装完成后,应用会自动启动

五、常见问题 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 目录或同步不完整

解决方案:

  1. 确认文件结构正确:
web_engine/src/main/resources/resfile/resources/app/
├── main.js
├── renderer.js
├── index.html
└── styles/
    └── freeplane.css
  1. 从 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
  1. 验证文件加载:
// 在 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 兼容问题
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐