画布交互: - 滚轮缩放视口(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)
178 lines
8.6 KiB
TypeScript
178 lines
8.6 KiB
TypeScript
import { ipcRenderer, contextBridge } from 'electron';
|
||
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../shared/constants/ipc-channels';
|
||
|
||
// --------- Expose some API to the Renderer process ---------
|
||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||
const [channel, listener] = args;
|
||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
||
},
|
||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||
const [channel, ...omit] = args;
|
||
return ipcRenderer.off(channel, ...omit);
|
||
},
|
||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||
const [channel, ...omit] = args;
|
||
return ipcRenderer.send(channel, ...omit);
|
||
},
|
||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||
const [channel, ...omit] = args;
|
||
return ipcRenderer.invoke(channel, ...omit);
|
||
},
|
||
});
|
||
|
||
// --------- 安全加密 API(基于 Electron safeStorage)---------
|
||
contextBridge.exposeInMainWorld('safeStorage', {
|
||
/**
|
||
* 加密明文字符串
|
||
* @param plaintext - 待加密的明文
|
||
* @returns Base64 编码的密文,失败返回 null
|
||
*/
|
||
async encrypt(plaintext: string): Promise<string | null> {
|
||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||
return result?.success ? result.data : null;
|
||
},
|
||
|
||
/**
|
||
* 解密 Base64 密文
|
||
* @param encryptedBase64 - Base64 编码的密文
|
||
* @returns 解密后的明文,失败返回 null
|
||
*/
|
||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||
return result?.success ? result.data : null;
|
||
},
|
||
});
|
||
|
||
// --------- 本地存储文件 API(主进程 fs 桥接)---------
|
||
contextBridge.exposeInMainWorld('fileStorage', {
|
||
/** 获取 heixiu-data 根目录路径 */
|
||
getDataDir(): Promise<string | null> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR)
|
||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||
},
|
||
/** 写入文件(Base64 → 磁盘),返回写入的绝对路径 */
|
||
writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding })
|
||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||
},
|
||
/** 读取文件(磁盘 → Base64) */
|
||
readFile(relativePath: string): Promise<string | null> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath })
|
||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||
},
|
||
/** 删除文件 */
|
||
deleteFile(relativePath: string): Promise<boolean> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath })
|
||
.then((r: { success: boolean }) => r?.success ?? false);
|
||
},
|
||
/** 递归创建目录 */
|
||
makeDir(relativePath: string): Promise<string | null> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath })
|
||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
||
},
|
||
/** 在文件管理器中显示文件(绝对路径) */
|
||
showItemInFolder(filePath: string): Promise<boolean> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath })
|
||
.then((r: { success: boolean }) => r?.success ?? false);
|
||
},
|
||
/** 获取文件大小(字节) */
|
||
fileSize(relativePath: string): Promise<number | null> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath })
|
||
.then((r: { success: boolean; data: number }) => r?.success ? r.data : null);
|
||
},
|
||
/** 检查文件/目录是否存在 */
|
||
fileExists(relativePath: string): Promise<boolean> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath })
|
||
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
|
||
},
|
||
});
|
||
|
||
/** 应用运行时 API */
|
||
contextBridge.exposeInMainWorld('appRuntime', {
|
||
/**
|
||
* 登录/登出后更新窗口标题中的版控后缀
|
||
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
||
*/
|
||
setWindowEdition(edition: string | null): void {
|
||
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
||
},
|
||
});
|
||
|
||
// --------- 无限画布 API ---------
|
||
contextBridge.exposeInMainWorld('canvas', {
|
||
/** 打开画布窗口(主进程创建新 BrowserWindow) */
|
||
openWindow(projectPath?: string): void {
|
||
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
|
||
},
|
||
/** 扫描目录,返回匹配的文件列表 */
|
||
scanDirectory(params: {
|
||
roots: string[];
|
||
extensions: string[];
|
||
recursive: boolean;
|
||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
|
||
},
|
||
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory,推荐使用) */
|
||
scanProject(params: {
|
||
projectPath: string;
|
||
stage: string;
|
||
assetTypeFilter: string;
|
||
shotQuery: string;
|
||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
|
||
},
|
||
/** 读取旁加载 JSON 元数据 */
|
||
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
|
||
},
|
||
/** 写入旁加载 JSON 元数据 */
|
||
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
|
||
},
|
||
/** 生成缩略图(JPEG base64) */
|
||
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
||
success: boolean;
|
||
data: { data: string; mimeType: string; width: number; height: number };
|
||
error?: string;
|
||
}> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
|
||
},
|
||
/** 复制文件到目标目录(导入操作) */
|
||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
|
||
},
|
||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
|
||
},
|
||
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
||
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
|
||
},
|
||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
|
||
},
|
||
/** 创建目录 */
|
||
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
|
||
},
|
||
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
|
||
getMediaUrl(filePath: string): string {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority,
|
||
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
|
||
if (normalized.startsWith('//')) {
|
||
// normalized = "//192.168.110.250/share/path"
|
||
return 'local-media://' + normalized.slice(2);
|
||
}
|
||
// 本地路径:C:/path → local-media:///C:/path
|
||
return 'local-media:///' + normalized;
|
||
},
|
||
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
||
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
|
||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
|
||
},
|
||
});
|