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)
This commit is contained in:
2026-06-23 20:11:50 +08:00
parent dd8f84ae27
commit 4ea414c9c7
38 changed files with 5193 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import {app, BrowserWindow, ipcMain, dialog, session} from 'electron';
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';
@@ -13,7 +13,8 @@ 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 {BIDIRECTIONAL, RENDERER_TO_MAIN} from '../shared/constants/ipc-channels';
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));
@@ -34,6 +35,8 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
// ---------- 平台 & 版本信息 ----------
const currentPlatform = getPlatform();
/** 当前版控后缀(登录后通过 IPC 更新) */
let currentEdition: string | null = null;
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
@@ -63,6 +66,7 @@ process.on('unhandledRejection', (reason) => {
// ============================================================
let mainWindow: BrowserWindow | null = null;
let canvasWindow: BrowserWindow | null = null;
// ============================================================
// 主窗口
@@ -125,6 +129,85 @@ function createMainWindow(): BrowserWindow {
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,
},
},
]);
// ============================================================
// 应用生命周期
// ============================================================
@@ -150,6 +233,33 @@ app.whenReady().then(() => {
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();
@@ -166,8 +276,9 @@ app.whenReady().then(() => {
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
if (!mainWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(mainWindow, {
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'],
@@ -176,6 +287,7 @@ app.whenReady().then(() => {
// 窗口标题版控后缀(渲染进程登录/登出后调用)
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')
@@ -183,6 +295,35 @@ app.whenReady().then(() => {
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();