Files
ele-HeiXiu/electron/main.ts
YoungestSongMo 4ea414c9c7 feat: 无限画布滚轮缩放 + 项目管理入口 (0.0.26)
画布交互:
- 滚轮缩放视口(2%~1600%),纯滚轮 10%/tick,Ctrl/Cmd+滚轮 3%/tick 精细控制
- 鼠标拖拽平移画布,Ctrl/Cmd+0 重置缩放
- 预览打开时锁定底层视口(previewActiveRef 事件穿透防护)
- 预览内图片独立滚轮缩放 + 拖拽平移 + 缩放指示器

文件拖拽:
- 画布缩略图/预览支持原生文件拖拽到外部应用(startDrag IPC)

Bug 修复:
- local-media:// 协议支持 UNC 网络路径
- passive event listener 报错:window 级原生 wheel listener 替代 React onWheel
- Zustand v5 getState() 批处理旧值:改用 ref(currentZoomRef + setZoomRef)读写

主进程(electron/main.ts):
- 画布窗口 before-input-event 拦截 Chromium 页面缩放
- 新增 canvas-ipc.ts(startDrag、文件扫描等 IPC handler)
2026-06-23 20:11:50 +08:00

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {app, BrowserWindow, ipcMain, dialog, session, protocol, net} from 'electron';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
import {getPlatform, isMacOS} from './main/utils/platform';
import {getWindowIconPath} from './main/utils/logo';
import {buildWindowTitle} from '../shared/constants/app';
import {loadEnvFile} from './main/load-env';
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
import {setupAppMenu} from './main/menu';
import {initLogger, flushLogger, logger} from './main/logger';
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
import {registerStorageIpcHandlers} from './main/ipc/storage-ipc';
import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc';
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels';
createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, '..');
// 加载 .env 文件中的环境变量到 process.env
// 必须在任何读取 process.env.UPDATE_API_URL 的模块之前调用
loadEnvFile();
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, 'public')
: RENDERER_DIST;
// ---------- 平台 & 版本信息 ----------
const currentPlatform = getPlatform();
/** 当前版控后缀(登录后通过 IPC 更新) */
let currentEdition: string | null = null;
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
// ============================================================
// 全局未捕获异常(主进程兜底记录)
// ============================================================
process.on('uncaughtException', (error) => {
try {
logger.error('app', 'Uncaught exception (main process)', error);
} catch {
/* logger 自身异常 — 最后防线 */
}
});
process.on('unhandledRejection', (reason) => {
try {
const error = reason instanceof Error ? reason : new Error(String(reason));
logger.error('app', 'Unhandled rejection (main process)', error);
} catch {
/* logger 自身异常 — 最后防线 */
}
});
// ============================================================
// 窗口引用
// ============================================================
let mainWindow: BrowserWindow | null = null;
let canvasWindow: BrowserWindow | null = null;
// ============================================================
// 主窗口
// ============================================================
function createMainWindow(): BrowserWindow {
const iconPath = getWindowIconPath(app.getAppPath());
mainWindow = new BrowserWindow({
title: WINDOW_TITLE,
icon: iconPath,
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
show: false,
...(isMacOS() ? {titleBarStyle: 'hiddenInset' as const} : {}),
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.setTitle(WINDOW_TITLE);
if (VITE_DEV_SERVER_URL) {
mainWindow.webContents.openDevTools();
}
// macOS 全屏事件
if (isMacOS()) {
mainWindow.on('enter-full-screen', () =>
mainWindow?.webContents.send('window-fullscreen-changed', true),
);
mainWindow.on('leave-full-screen', () =>
mainWindow?.webContents.send('window-fullscreen-changed', false),
);
}
mainWindow.webContents.on('did-finish-load', () => {
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
mainWindow?.webContents.send('platform-info', {
platform: currentPlatform,
edition: currentEdition,
});
});
// ready-to-show 后显示,避免白屏闪烁
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
}
return mainWindow;
}
// ============================================================
// 画布窗口
// ============================================================
function createCanvasWindow(): BrowserWindow {
const iconPath = getWindowIconPath(app.getAppPath());
logger.info('canvas-window', '创建画布窗口', {
isDev: !!VITE_DEV_SERVER_URL,
url: VITE_DEV_SERVER_URL
? VITE_DEV_SERVER_URL + '#/canvas'
: path.join(RENDERER_DIST, 'index.html') + '#/canvas',
});
canvasWindow = new BrowserWindow({
title: '无限画布 - HeiXiu',
icon: iconPath,
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
if (VITE_DEV_SERVER_URL) {
canvasWindow.loadURL(VITE_DEV_SERVER_URL + '#/canvas');
canvasWindow.webContents.openDevTools();
logger.info('canvas-window', '画布窗口 DevTools 已自动打开(开发模式)');
} else {
canvasWindow.loadFile(path.join(RENDERER_DIST, 'index.html'), {
hash: '/canvas',
});
}
// 禁用浏览器默认页面缩放Ctrl/Cmd+滚轮 和 触控板捏合)。
// 画布使用 CSS transform 实现独立缩放,不允许 Chromium 页面级缩放干扰。
// before-input-event 在 Chromium 处理输入之前触发,比 JS event listener 更早。
canvasWindow.webContents.on('before-input-event', (event, input) => {
if (input.type === 'mouseWheel' && (input.control || input.meta)) {
event.preventDefault();
}
});
canvasWindow.once('ready-to-show', () => {
logger.info('canvas-window', '画布窗口 ready-to-show即将显示');
canvasWindow?.show();
});
canvasWindow.on('closed', () => {
logger.info('canvas-window', '画布窗口已关闭');
canvasWindow = null;
});
return canvasWindow;
}
// ============================================================
// 自定义协议local-media — 安全加载本地文件
// 必须在 app.whenReady 之前注册为特权 scheme
// ============================================================
protocol.registerSchemesAsPrivileged([
{
scheme: 'local-media',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
bypassCSP: true,
stream: true,
},
},
]);
// ============================================================
// 应用生命周期
// ============================================================
app.on('window-all-closed', () => {
if (!isMacOS()) {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
app.on('before-quit', () => {
flushLogger();
});
app.whenReady().then(() => {
initLogger();
registerLogIpcHandlers();
registerSafeStorageIpcHandlers();
registerStorageIpcHandlers();
registerCanvasIpcHandlers();
// 注册 local-media:// 自定义协议(用于全屏预览加载本地文件)
// 支持两种 URL 格式:
// 本地路径local-media:///C:/Users/... (authority 为空,路径以 / 开头)
// UNC 路径local-media://192.168.110.250/share/... (authority 为服务器主机名)
protocol.handle('local-media', (request) => {
const urlObj = new URL(request.url);
let filePath: string;
if (urlObj.hostname) {
// UNC 路径local-media://server/share/path → \\server\share\path
filePath = '\\\\' + urlObj.hostname + decodeURIComponent(urlObj.pathname).replace(/\//g, '\\');
} else {
// 本地路径local-media:///C:/path/to/file → C:/path/to/file
filePath = decodeURIComponent(urlObj.pathname.slice(1));
}
const fwd = filePath.replace(/\\/g, '/');
if (fwd.startsWith('//')) {
// UNCfile://server/share/pathauthority = server
return net.fetch('file:' + fwd);
}
// 本地file:///C:/path/to/fileauthority 为空)
return net.fetch('file:///' + fwd);
});
setupAppMenu();
app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers();
const fakeUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const url = details.url;
if (url.includes("rh-images-1252422369.cos.ap-beijing.myqcloud.com")) {
details.requestHeaders['User-Agent'] = fakeUserAgent;
}
callback({
requestHeaders: details.requestHeaders
});
});
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow;
if (!parentWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(parentWindow, {
title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'],
});
});
// 窗口标题版控后缀(渲染进程登录/登出后调用)
ipcMain.on(RENDERER_TO_MAIN.SET_WINDOW_TITLE, (_event, edition: string | null) => {
currentEdition = edition;
if (!mainWindow) return;
const title = edition
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
: buildWindowTitle(currentPlatform);
mainWindow.setTitle(title);
});
// 打开无限画布窗口(渲染进程请求 → 主进程创建新 BrowserWindow
ipcMain.on(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, (_event, payload?: { projectPath?: string }) => {
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
projectPath: payload?.projectPath || '(未指定)',
existingAlive,
});
if (existingAlive) {
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
canvasWindow?.focus();
} else {
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
canvasWindow = createCanvasWindow();
}
// 画布页面加载完成后,发送初始化参数
if (payload?.projectPath) {
const readyHandler = () => {
logger.info('canvas-window', '画布页面 did-finish-load发送 CANVAS_READY', {
projectPath: payload.projectPath,
});
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
projectPath: payload.projectPath,
});
};
canvasWindow?.webContents.on('did-finish-load', readyHandler);
}
});
// 始终打开主窗口
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
const win = createMainWindow();
initUpdater(win);
});