欢迎光临
我们一直在努力

鸿蒙平台 Xournal++ 手绘笔记适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移指南

项目简介

Xournal++ 是 Linux 平台知名的开源手绘笔记应用,支持自由绘制、形状工具、文本编辑、素材库、导出等功能。本项目将其从 Linux 应用迁移到鸿蒙平台,采用 Electron 核心功能 + 鸿蒙壳工程 的架构模式,基于 Excalidraw 手绘引擎实现。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_xournal_electron

核心功能

  • ✏️ 自由绘制(铅笔工具,支持压感手绘,流畅自然)
  • 🔷 形状工具(矩形、圆形、菱形、箭头、线条,智能吸附)
  • 📝 文本编辑(插入文本框,支持富文本编辑)
  • 🎨 颜色选择(描边/背景/填充颜色,预设调色板)
  • 🔒 锁定/解锁(锁定元素防止误操作)
  • 🔗 链接功能(为元素添加超链接)
  • 📚 素材库(保存常用元素,快速复用,全中文界面)
  • 🗑️ 橡皮擦(擦除不需要的内容)
  • ↩️ 撤销/重做(Ctrl+Z/Y,无限历史)
  • 💾 文件操作(新建/打开/保存 .excalidraw 格式)
  • 🖼️ 导出 PNG(导出画布为 PNG 图片)
  • 🌐 **全中文 **UI(Library→素材库,Help→帮助,完整中文化)

一、技术架构

1.1 原始架构(Linux Desktop)

Xournal++ (C++/GTK Linux Desktop)
├── UI 渲染:GTK+ 3 Widget
├── 画布引擎:Cairo 2D 图形库
├── 笔迹处理:自定义压感算法
└── 文件系统:GIO/GFile

1.2 目标架构(鸿蒙 Electron)

鸿蒙壳工程 (ArkTS)
└── web_engine 模块 (XComponent WebView)
└── Electron 应用 (HTML/CSS/JavaScript)
├── main.js – Electron 主进程
├── renderer.js – 渲染进程(核心逻辑)
├── index.html – UI 界面
├── package.json – 项目配置
└── styles/
└── xournal.css – 样式文件

1.3 架构优势

  • 跨平台:Electron 代码可在 Windows/macOS/Linux 复用
  • 快速开发:Web 技术栈,开发效率高
  • 易于维护:UI 和业务逻辑分离
  • 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
  • Excalidraw 引擎:成熟的手绘引擎,开箱即用

二、环境准备

2.1 开发环境要求

  • 操作系统:Windows 10
  • 开发工具:DevEco Studio(鸿蒙官方 IDE)
  • HarmonyOS SDK:API 15+
  • 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/
└── xournal.css # 样式文件
└── build-profile.json5 # 鸿蒙构建配置

三、核心适配流程

3.1 第一步:创建 Electron 主进程(main.js)

文件:web_engine/src/main/resources/resfile/resources/app/main.js

// Xournal Note App – Electron 主进程
// 基于 Excalidraw 的手绘笔记应用(鸿蒙适配)

const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const path = require('path');
const fs = require('fs');

let mainWindow = null;

function createWindow() {
console.log('Xournal: 创建窗口…');

// 获取屏幕尺寸
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('Xournal: 正在加载 index.html:', path.join(__dirname, 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'index.html'));

console.log('Xournal: 窗口创建成功,尺寸:', windowWidth, 'x', windowHeight);

mainWindow.on('closed', () => {
console.log('Xournal: 窗口已关闭');
mainWindow = null;
});

mainWindow.webContents.on('did-finish-load', () => {
console.log('Xournal: 页面加载成功');
});

mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Xournal: 页面加载失败:', errorCode, errorDescription);
});

setupIpcHandlers();
}

function setupIpcHandlers() {
console.log('Xournal: 设置 IPC 处理器');

// 打开文件对话框
ipcMain.handle('open-file-dialog', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [
{ name: 'Excalidraw Files', extensions: ['excalidraw'] },
{ name: 'All Files', extensions: ['*'] }
]
});

if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});

// 保存文件对话框
ipcMain.handle('save-file-dialog', async (event, defaultPath) => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: defaultPath || 'untitled.excalidraw',
filters: [
{ name: 'Excalidraw Files', extensions: ['excalidraw'] },
{ name: 'All Files', extensions: ['*'] }
]
});

return result.filePath;
});

// 读取文件
ipcMain.handle('read-file', async (event, filePath) => {
try {
const data = fs.readFileSync(filePath, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('Xournal: 读取文件失败:', error);
return null;
}
});

// 写入文件
ipcMain.handle('write-file', async (event, filePath, data) => {
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error('Xournal: 写入文件失败:', error);
return false;
}
});

// 导出为 PNG
ipcMain.handle('export-png-dialog', async () => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: 'export.png',
filters: [
{ name: 'PNG 图像', extensions: ['png'] }
]
});

return result.filePath;
});

// 导出为 PDF
ipcMain.handle('export-pdf-dialog', async () => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: 'export.pdf',
filters: [
{ name: 'PDF 文档', extensions: ['pdf'] }
]
});

return result.filePath;
});
}

app.whenReady().then(() => {
createWindow();
console.log('Xournal 手绘笔记应用已启动');
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

关键要点:

  • 窗口尺寸动态计算(屏幕 95% 宽度 × 90% 高度)
  • 提供 5 个核心 IPC 接口:文件打开、文件保存、文件读写、PNG 导出、PDF 导出
  • 支持 .excalidraw 格式(JSON 结构)
  • fs.readFileSync/WriteSync 实现文件持久化
  • 全中文日志输出,便于调试

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>Xournal++ – 手绘笔记</title>
<link rel="stylesheet" href="styles/xournal.css">
</head>
<body>
<!– 主应用容器 –>
<div id="app" class="app-container">
<!– 顶部工具栏 –>
<header class="toolbar">
<div class="toolbar-left">
<button id="btn-new" class="btn btn-icon" title="新建 (Ctrl+N)">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13zM6 20V4h6v6h6v10H6z"/>
</svg>
</button>
<button id="btn-open" class="btn btn-icon" title="打开 (Ctrl+O)">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
</svg>
</button>
<button id="btn-save" class="btn btn-icon" title="保存 (Ctrl+S)">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M17 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</svg>
</button>
</div>

<div class="toolbar-center">
<h1 class="app-title">Xournal++ 笔记</h1>
</div>

<div class="toolbar-right">
<button id="btn-export-png" class="btn btn-icon" title="导出 PNG">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2v9.67z"/>
</svg>
</button>
<button id="btn-export-pdf" class="btn btn-icon" title="导出 PDF">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.5 7.5c0 .83-.67 1.5-1.5 1.5H9v2H7.5V7H10c.83 0 1.5.67 1.5 1.5v1zm5 2c0 .83-.67 1.5-1.5 1.5h-2.5V7H15c.83 0 1.5.67 1.5 1.5v3zm4-3H19v1h1.5V11H19v2h-1.5V7h3v1.5zM9 9.5h1v-1H9v1zM14 9.5h1v-2h-1v2z"/>
</svg>
</button>
</div>
</header>

<!– 主内容区 – Excalidraw 画布 –>
<main class="main-content">
<div id="excalidraw-root"></div>
</main>

<!– 底部状态栏 –>
<footer class="status-bar">
<span id="status-text">就绪 – 开始手绘笔记</span>
<span id="element-count">0 个元素</span>
</footer>
</div>

<!– 引入 React 和 Excalidraw(本地化版本,避免网络依赖) –>
<!– 开发阶段使用 CDN,生产环境需下载到本地 –>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@excalidraw/excalidraw@0.17.6/dist/excalidraw.production.min.js" crossorigin></script>

<!– 引入渲染进程脚本 –>
<script src="renderer.js"></script>
</body>
</html>

关键要点:

  • 单栏式布局(顶部工具栏 + 画布容器 + 底部状态栏)
  • Material Design SVG 图标系统(新建/打开/保存/导出)
  • 工具栏包含:文件操作(新建/打开/保存)+ 导出功能(PNG/PDF)
  • Excalidraw 画布容器(#excalidraw-root)
  • 状态栏显示元素数量和实时状态

3.3 第三步:配置项目元信息(package.json)

文件:web_engine/src/main/resources/resfile/resources/app/package.json

{
"name": "xournal-handwrite-note",
"version": "1.0.0",
"description": "Xournal++ 轻量版 – 基于 Excalidraw 的手绘笔记应用(鸿蒙适配)",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . –dev"
},
"keywords": ["笔记", "白板", "手绘", "excalidraw", "xournal"],
"author": "Excalidraw 团队(鸿蒙移植版)",
"license": "MIT",
"dependencies": {
"@excalidraw/excalidraw": "^0.17.6",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"electron": "^28.0.0"
}
}

关键要点:

  • main** 入口**:指定 main.js 为 Electron 主进程入口文件
  • scripts 脚本:start 启动生产模式,dev 启动开发模式(带调试参数)
  • license 协议:MIT(与 Excalidraw 保持一致)
  • electron 版本:^28.0.0(兼容鸿蒙 ArkWeb 的 Electron 版本)
  • keywords:包含笔记、白板、手绘、excalidraw 等中文搜索关键词
  • dependencies:React 18.2.0 + Excalidraw 0.17.6

3.4 第四步:实现渲染进程核心逻辑(renderer.js)

文件:web_engine/src/main/resources/resfile/resources/app/renderer.js

// Xournal Note App – 渲染进程核心逻辑
// 基于 Excalidraw 的手绘笔记应用(鸿蒙适配)

// 全局状态
let excalidrawAPI = null;
let currentFile = null;
let elementCount = 0;
let eventsInitialized = false;

// 初始化
document.addEventListener('DOMContentLoaded', () => {
console.log('Xournal: DOM 已加载,正在初始化 Excalidraw…');
initExcalidraw();
bindEvents();
});

// 初始化 Excalidraw
function initExcalidraw() {
const excalidrawRoot = document.getElementById('excalidraw-root');

if (!excalidrawRoot) {
console.error('Xournal: 未找到 excalidraw-root 元素');
return;
}

// 检查 Excalidraw 库是否加载
if (typeof window.ExcalidrawLib === 'undefined') {
console.error('Xournal: Excalidraw 库未从 CDN 加载');
showStatus('Excalidraw 库加载失败,请检查网络连接');

// 显示错误提示
excalidrawRoot.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #666;">
<svg width="80" height="80" viewBox="0 0 24 24" fill="#999">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
<p style="margin-top: 16px; font-size: 16px;">Excalidraw 库加载失败</p>
<p style="margin-top: 8px; font-size: 13px; color: #999;">请检查网络连接后刷新页面</p>
</div>
`
;
return;
}

const { Excalidraw } = window.ExcalidrawLib;

console.log('Xournal: ExcalidrawLib 已加载,版本:', window.ExcalidrawLib.version || '未知');

// 创建 React 组件
const ExcalidrawApp = () => {
const excalidrawWrapper = React.useRef(null);
const apiRef = React.useRef(null);

React.useEffect(() => {
if (excalidrawWrapper.current && !apiRef.current) {
console.log('Xournal: 正在渲染 Excalidraw 组件…');

const excalidrawElement = React.createElement(Excalidraw, {
langCode: 'zh-CN',
UIOptions: {
canvasActions: {
export: { saveFileToDisk: true }
}
},
initialData: {
elements: [],
appState: {
viewModeEnabled: false,
zenModeEnabled: false,
gridModeEnabled: true,
theme: 'light'
}
},
onChange: (elements, appState) => {
elementCount = elements.length;
updateElementCount();
},
excalidrawAPI: (api) => {
apiRef.current = api;
excalidrawAPI = api;
console.log('Xournal: Excalidraw API 初始化成功');
}
});

ReactDOM.render(excalidrawElement, excalidrawWrapper.current);
}
}, []);

return React.createElement('div', {
ref: excalidrawWrapper,
style: { width: '100%', height: '100%' }
});
};

// 挂载 React 应用
console.log('Xournal: 正在挂载 React 应用…');
ReactDOM.render(
React.createElement(ExcalidrawApp),
excalidrawRoot
);
console.log('Xournal: React 应用已挂载');
}

// 绑定事件
function bindEvents() {
if (eventsInitialized) {
console.warn('Xournal: 事件已绑定');
return;
}
eventsInitialized = true;

// 新建
document.getElementById('btn-new').addEventListener('click', newFile);

// 打开
document.getElementById('btn-open').addEventListener('click', openFile);

// 保存
document.getElementById('btn-save').addEventListener('click', saveFile);

// 导出 PNG
document.getElementById('btn-export-png').addEventListener('click', exportPNG);

// 导出 PDF
document.getElementById('btn-export-pdf').addEventListener('click', exportPDF);

// 键盘快捷键
document.addEventListener('keydown', handleKeyboard);
}

// 新建文件
async function newFile() {
if (excalidrawAPI) {
excalidrawAPI.resetScene();
currentFile = null;
elementCount = 0;
updateElementCount();
showStatus('已新建文件');
}
}

// 打开文件
async function openFile() {
try {
const filePath = await window.electronAPI.invoke('open-file-dialog');
if (!filePath) return;

showStatus('正在加载文件…');
const data = await window.electronAPI.invoke('read-file', filePath);

if (data && excalidrawAPI) {
excalidrawAPI.updateScene({
elements: data.elements || []
});

currentFile = filePath;
elementCount = data.elements ? data.elements.length : 0;
updateElementCount();
showStatus(`已打开: ${filePath}`);
} else {
showStatus('文件加载失败');
}
} catch (error) {
console.error('Xournal: 打开文件失败:', error);
showStatus('打开文件失败');
}
}

// 保存文件
async function saveFile() {
if (!excalidrawAPI) {
showStatus('Excalidraw 未初始化');
return;
}

try {
const sceneData = excalidrawAPI.getSceneElements();

// 如果没有当前文件,弹出保存对话框
let filePath = currentFile;
if (!filePath) {
filePath = await window.electronAPI.invoke('save-file-dialog');
if (!filePath) return;
currentFile = filePath;
}

showStatus('正在保存…');
const success = await window.electronAPI.invoke('write-file', filePath, {
elements: sceneData,
appState: {
viewModeEnabled: false,
zenModeEnabled: false,
gridModeEnabled: true
}
});

if (success) {
showStatus(`已保存: ${filePath}`);
} else {
showStatus('保存失败');
}
} catch (error) {
console.error('Xournal: 保存文件失败:', error);
showStatus('保存文件失败');
}
}

// 导出为 PNG
async function exportPNG() {
if (!excalidrawAPI) {
showStatus('Excalidraw 未初始化');
return;
}

try {
showStatus('正在准备导出 PNG…');

// 获取场景数据
const elements = excalidrawAPI.getSceneElements();
const appState = excalidrawAPI.getAppState();

if (elements.length === 0) {
showStatus('画布为空,无法导出');
return;
}

// 使用 Excalidraw 的导出功能
const exportData = await excalidrawAPI.exportToBlob({
mimeType: 'image/png',
elements: elements,
appState: { appState, exportBackground: true },
files: excalidrawAPI.getFiles()
});

// 创建下载链接
const url = URL.createObjectURL(exportData);
const a = document.createElement('a');
a.href = url;
a.download = 'xournal-export.png';
a.click();
URL.revokeObjectURL(url);

showStatus('PNG 导出成功');
} catch (error) {
console.error('Xournal: 导出 PNG 失败:', error);
showStatus('导出 PNG 失败: ' + error.message);
}
}

// 导出为 PDF
async function exportPDF() {
if (!excalidrawAPI) {
showStatus('Excalidraw 未初始化');
return;
}

try {
showStatus('正在准备导出 PDF…');

// 获取场景数据
const elements = excalidrawAPI.getSceneElements();

if (elements.length === 0) {
showStatus('画布为空,无法导出');
return;
}

// PDF 导出需要额外库支持,暂时提示
showStatus('PDF 导出功能需要使用 jsPDF 库,请在 package.json 中添加依赖');

// TODO: 实现 PDF 导出
// 1. npm install jspdf
// 2. 使用 jsPDF 创建 PDF
// 3. 将 Canvas 内容绘制到 PDF
} catch (error) {
console.error('Xournal: 导出 PDF 失败:', error);
showStatus('导出 PDF 失败: ' + error.message);
}
}

// 键盘快捷键
function handleKeyboard(e) {
// Ctrl + N 新建
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
newFile();
return;
}

// Ctrl + O 打开
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
openFile();
return;
}

// Ctrl + S 保存
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveFile();
return;
}
}

// 工具函数
function showStatus(text) {
const statusText = document.getElementById('status-text');
if (statusText) {
statusText.textContent = text;
}
}

function updateElementCount() {
const countElement = document.getElementById('element-count');
if (countElement) {
countElement.textContent = `${elementCount} 个元素`;
}
}

// 暴露 API 供主进程调用
window.electronAPI = require('electron').ipcRenderer;

关键要点:

  • Excalidraw 集成:langCode: ‘zh-CN’ 启用完整中文界面
  • React 18 组件化:使用 createElement 动态渲染
  • 事件绑定守卫(eventsInitialized),防止热重载重复绑定
  • CDN 加载失败友好提示:显示错误图标和引导文字
  • 文件操作:新建/打开/保存 .excalidraw 格式
  • PNG 导出:使用 Excalidraw 原生 exportToBlob API
  • 全中文日志输出,便于调试

3.5 第五步:编写样式文件(xournal.css)

文件:web_engine/src/main/resources/resfile/resources/app/styles/xournal.css

/* Xournal Note App – 主样式文件 */
/* 鸿蒙 ArkWeb 兼容:不使用 CSS 变量 */

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
background: #f5f5f5;
color: #333;
overflow: hidden;
}

/* 主应用容器 */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}

/* 顶部工具栏 */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #ffffff;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

.toolbar-left,
.toolbar-right
{
display: flex;
gap: 8px;
}

.toolbar-center {
flex: 1;
text-align: center;
}

.app-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}

/* 按钮样式 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: #333;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
position: relative;
}

.btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}

.btn:hover {
background: rgba(0, 0, 0, 0.05);
color: #0078d4;
}

.btn:active {
transform: scale(0.95);
}

.btn-active {
background: #0078d4;
color: #ffffff;
}

.btn-active:hover {
background: #106ebe;
}

/* 工具栏分隔线 */
.toolbar-separator {
width: 1px;
height: 24px;
background: #e0e0e0;
margin: 0 4px;
}

/* 主内容区 */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
background: #ffffff;
}

/* Excalidraw 容器 */
#excalidraw-root {
width: 100%;
height: 100%;
}

/* 底部状态栏 */
.status-bar {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #ffffff;
border-top: 1px solid #e0e0e0;
font-size: 13px;
color: #666;
}

#element-count {
color: #0078d4;
font-weight: 500;
}

/* Excalidraw 自定义样式 */
/* 鸿蒙 ArkWeb 兼容:不使用 CSS 变量,直接覆盖 Excalidraw 主题色 */
.excalidraw {
/* 注:Excalidraw 内部使用这些 CSS 变量控制主题 */
/* 鸿蒙 ArkWeb 不支持 –xxx 语法,但 Excalidraw 运行时会在其内部 Shadow DOM 中定义 */
/* 此处保留注释说明,实际样式由 Excalidraw 自身管理 */
}

/* 鸿蒙 ArkWeb 兼容:移除 ::-webkit-scrollbar 样式 */
/* 滚动条样式不使用,鸿蒙不支持 */

关键要点:

  • 浅色主题设计:使用 #ffffff、#f5f5f5 等亮色系,专业笔记应用风格
  • 单栏布局:顶部工具栏 + 画布容器(100% 自适应)+ 底部状态栏
  • Material Design 按钮:悬停高亮 #0078d4,点击缩放 0.95 倍
  • 工具栏阴影:box-shadow: 0 2px 4px 轻微立体感
  • 状态栏分隔:左侧状态提示,右侧元素计数(蓝色高亮)
  • 鸿蒙 ArkWeb 兼容:不使用 CSS 变量(var(–xxx)),直接写实际颜色值
  • 移除 Webkit 滚动条样式:鸿蒙不支持 ::-webkit-scrollbar,已删除

四、部署到鸿蒙平台

4.1 项目结构说明

开发****工作流:

  • 直接在 electron-apps/xournal/ 中修改代码
  • 同步到 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:Excalidraw 界面显示英文而不是中文?

    问题现象:Excalidraw 的 Library、Help 等菜单显示英文

    根本原因:未配置 langCode 属性

    解决方案:

    const excalidrawElement = React.createElement(Excalidraw, {
    langCode: 'zh-CN', // ⭐ 启用中文界面
    UIOptions: {
    canvasActions: {
    export: { saveFileToDisk: true }
    }
    },
    // … 其他配置
    });

    关键点:

    • langCode: ‘zh-CN’ 激活 Excalidraw 内置中文翻译包
    • Library 自动显示为"素材库"
    • Help 自动显示为"帮助"
    • 所有工具栏菜单自动中文化

    Q2:CDN 加载失败导致白屏?

    问题现象:应用启动后显示空白页面

    根本原因:unpkg.com 网络不可达或 CDN 资源加载失败

    解决方案:

    if (typeof window.ExcalidrawLib === 'undefined') {
    console.error('Xournal: Excalidraw 库未从 CDN 加载');
    showStatus('Excalidraw 库加载失败,请检查网络连接');

    // ⭐ 显示友好错误提示
    excalidrawRoot.innerHTML = `
    <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #666;">
    <svg width="80" height="80" viewBox="0 0 24 24" fill="#999">
    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
    </svg>
    <p style="margin-top: 16px; font-size: 16px;">Excalidraw 库加载失败</p>
    <p style="margin-top: 8px; font-size: 13px; color: #999;">请检查网络连接后刷新页面</p>
    </div>
    `
    ;
    return;
    }

    关键点:

    • 检测 window.ExcalidrawLib 是否存在
    • 显示错误图标和引导文字
    • 生产环境建议下载 CDN 资源到本地

    Q3:导出 PNG 功能导出空白图片?

    问题现象:点击导出 PNG 按钮,生成的图片是空白的

    根本原因:画布为空时未做检查

    解决方案:

    async function exportPNG() {
    const elements = excalidrawAPI.getSceneElements();

    if (elements.length === 0) {
    showStatus('画布为空,无法导出'); // ⭐ 提前检查
    return;
    }

    const exportData = await excalidrawAPI.exportToBlob({
    mimeType: 'image/png',
    elements: elements,
    appState: { appState, exportBackground: true },
    files: excalidrawAPI.getFiles()
    });

    // 创建下载链接
    const url = URL.createObjectURL(exportData);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'xournal-export.png';
    a.click();
    URL.revokeObjectURL(url); // ⭐ 释放内存
    }

    关键点:

    • 检查 elements.length === 0 避免导出空白
    • 使用 exportToBlob API 导出高质量 PNG
    • URL.revokeObjectURL 及时释放内存

    Q4:鸿蒙平台 CSS 样式不生效?

    问题现象:部分 CSS 样式在鸿蒙设备上未显示

    根本原因:鸿蒙 ArkWeb 不支持 CSS 自定义属性(变量)

    解决方案:

    /* ❌ 错误:使用 CSS 变量 */
    .toolbar {
    background: var(–toolbar-bg);
    }

    /* ✅ 正确:使用实际值 */
    .toolbar {
    background: #ffffff; /* 白色背景 */
    }

    /* ❌ 错误:使用 CSS 变量 */
    .btn:hover {
    background: var(–hover-color);
    }

    /* ✅ 正确:使用实际值 */
    .btn:hover {
    background: rgba(0, 0, 0, 0.05); /* 悬停高亮 */
    }

    关键点:

    • 将所有 CSS 变量替换为实际值
    • ArkWeb 不支持 var(–xxx) 自定义属性
    • 颜色值直接使用十六进制或 rgba
    • 其他 CSS 特性(flex、transition、transform)均支持

    Q5:打开 .excalidraw 文件后内容未显示?

    问题现象:选择文件后画布仍然空白

    根本原因:未正确解析 JSON 格式或未调用 updateScene

    解决方案:

    async function openFile() {
    const filePath = await window.electronAPI.invoke('open-file-dialog');
    const data = await window.electronAPI.invoke('read-file', filePath);

    if (data && excalidrawAPI) {
    excalidrawAPI.updateScene({
    elements: data.elements || [] // ⭐ 正确传递 elements 数组
    });

    currentFile = filePath;
    elementCount = data.elements ? data.elements.length : 0;
    updateElementCount();
    showStatus(`已打开: ${filePath}`);
    }
    }

    关键点:

    • 主进程使用 JSON.parse 解析文件
    • 渲染进程调用 updateScene 更新画布
    • 确保传递 data.elements 数组
    • 更新元素计数显示

    Q6:保存文件后格式不正确?

    问题现象:保存的 .excalidraw 文件无法再次打开

    根本原因:未正确序列化 JSON 或未包含 appState

    解决方案:

    async function saveFile() {
    const sceneData = excalidrawAPI.getSceneElements();

    const success = await window.electronAPI.invoke('write-file', filePath, {
    elements: sceneData,
    appState: { // ⭐ 必须包含 appState
    viewModeEnabled: false,
    zenModeEnabled: false,
    gridModeEnabled: true
    }
    });
    }

    关键点:

    • 使用 JSON.stringify(data, null, 2) 格式化输出
    • 必须包含 appState 对象
    • 主进程使用 utf8 编码写入
    • 文件扩展名使用 .excalidraw

    Q7:鸿蒙平台构建失败或文件未加载?

    问题现象:hvigor 构建时报错,或应用启动后白屏

    根本原因:文件未正确放置在 resfile 目录或同步不完整

    解决方案:

  • 确认文件结构正确:
  • web_engine/src/main/resources/resfile/resources/app/
    ├── main.js
    ├── renderer.js
    ├── index.html
    └── styles/
    └── xournal.css

  • 从 electron-apps 同步到 web_engine:
  • # 在 PowerShell 中执行
    Copy-Item -Path "electron-apps\\xournal\\main.js" -Destination "web_engine\\src\\main\\resources\\resfile\\resources\\app\\main.js" -Force
    Copy-Item -Path "electron-apps\\xournal\\renderer.js" -Destination "web_engine\\src\\main\\resources\\resfile\\resources\\app\\renderer.js" -Force
    Copy-Item -Path "electron-apps\\xournal\\index.html" -Destination "web_engine\\src\\main\\resources\\resfile\\resources\\app\\index.html" -Force
    Copy-Item -Path "electron-apps\\xournal\\styles\\xournal.css" -Destination "web_engine\\src\\main\\resources\\resfile\\resources\\app\\styles\\xournal.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:为什么 Xournal 比 gThumb 适配更简单?

    问题现象:Xournal 几乎不需要修改代码就能运行

    根本原因:Excalidraw 是纯 Web 标准技术栈

    技术解析:

    Xournal 技术栈:
    ├── React 18 ← 标准 Web 框架
    ├── Canvas API ← HTML5 标准 API
    ├── SVG 渲染 ← 矢量图形标准
    └── JavaScript ← 通用脚本语言

    关键点:

    • Excalidraw 使用 W3C 标准 API,不依赖平台特性
    • 鸿蒙 ArkWeb 基于 Chromium,完整支持现代 Web 标准
    • React/Vue/Canvas 等框架无需修改即可运行
    • 只处理了 CSS 变量和滚动条样式的兼容问题
    • 对比 gThumb:无需处理图片解码、文件系统路径等复杂逻辑

    核心优势:

    • 跨平台:Electron 代码可在 Windows/macOS/Linux 复用
    • 快速开发:Web 技术栈,开发效率高
    • 易于维护:UI 和业务逻辑分离
    • 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
    赞(0)
    未经允许不得转载:171主机测评 » 鸿蒙平台 Xournal++ 手绘笔记适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址