From 4ea414c9c74c13ff448ec3de349ade898c7716c6 Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Tue, 23 Jun 2026 20:11:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A0=E9=99=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=BB=9A=E8=BD=AE=E7=BC=A9=E6=94=BE=20+=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=85=A5=E5=8F=A3=20(0.0.26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画布交互: - 滚轮缩放视口(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) --- electron/electron-env.d.ts | 49 ++ electron/main.ts | 149 +++- electron/main/ipc/canvas-ipc.ts | 715 ++++++++++++++++++ electron/preload.ts | 79 +- shared/constants/ipc-channels.ts | 30 + src/components/frame/navbar/NavBar.tsx | 9 +- src/main.tsx | 32 +- src/pages/infinite-canvas/CanvasApp.tsx | 93 +++ src/pages/infinite-canvas/CanvasPage.tsx | 225 ++++++ .../infinite-canvas/core/CanvasViewport.tsx | 367 +++++++++ src/pages/infinite-canvas/core/LODImage.tsx | 144 ++++ src/pages/infinite-canvas/core/MediaCard.tsx | 187 +++++ .../core/MediaCardContextMenu.tsx | 163 ++++ .../infinite-canvas/core/MediaPreview.tsx | 345 +++++++++ .../infinite-canvas/core/ViewportControls.tsx | 217 ++++++ src/pages/infinite-canvas/core/index.ts | 11 + src/pages/infinite-canvas/hooks/index.ts | 7 + .../hooks/useCanvasInteraction.ts | 72 ++ .../infinite-canvas/hooks/useDirectoryScan.ts | 163 ++++ .../infinite-canvas/hooks/useSidecarSync.ts | 59 ++ .../navigation/ContextMenu.tsx | 39 + .../navigation/DirectoryCreateModal.tsx | 74 ++ .../navigation/EpisodeSelector.tsx | 103 +++ .../navigation/ProjectSelector.tsx | 96 +++ .../infinite-canvas/navigation/ShotList.tsx | 126 +++ src/pages/infinite-canvas/navigation/index.ts | 10 + .../infinite-canvas/store/useCanvasStore.ts | 384 ++++++++++ .../toolbar/CanvasFilterBar.tsx | 89 +++ .../infinite-canvas/toolbar/CanvasToolbar.tsx | 129 ++++ .../infinite-canvas/toolbar/StageTabs.tsx | 57 ++ src/pages/infinite-canvas/toolbar/index.ts | 7 + .../utils/ICanvasDataSource.ts | 183 +++++ .../utils/LocalFileDataSource.ts | 253 +++++++ src/pages/infinite-canvas/utils/constants.ts | 70 ++ src/pages/infinite-canvas/utils/index.ts | 30 + src/pages/infinite-canvas/utils/layout.ts | 170 +++++ src/pages/infinite-canvas/utils/scanner.ts | 252 ++++++ src/vite-env.d.ts | 22 + 38 files changed, 5193 insertions(+), 17 deletions(-) create mode 100644 electron/main/ipc/canvas-ipc.ts create mode 100644 src/pages/infinite-canvas/CanvasApp.tsx create mode 100644 src/pages/infinite-canvas/CanvasPage.tsx create mode 100644 src/pages/infinite-canvas/core/CanvasViewport.tsx create mode 100644 src/pages/infinite-canvas/core/LODImage.tsx create mode 100644 src/pages/infinite-canvas/core/MediaCard.tsx create mode 100644 src/pages/infinite-canvas/core/MediaCardContextMenu.tsx create mode 100644 src/pages/infinite-canvas/core/MediaPreview.tsx create mode 100644 src/pages/infinite-canvas/core/ViewportControls.tsx create mode 100644 src/pages/infinite-canvas/core/index.ts create mode 100644 src/pages/infinite-canvas/hooks/index.ts create mode 100644 src/pages/infinite-canvas/hooks/useCanvasInteraction.ts create mode 100644 src/pages/infinite-canvas/hooks/useDirectoryScan.ts create mode 100644 src/pages/infinite-canvas/hooks/useSidecarSync.ts create mode 100644 src/pages/infinite-canvas/navigation/ContextMenu.tsx create mode 100644 src/pages/infinite-canvas/navigation/DirectoryCreateModal.tsx create mode 100644 src/pages/infinite-canvas/navigation/EpisodeSelector.tsx create mode 100644 src/pages/infinite-canvas/navigation/ProjectSelector.tsx create mode 100644 src/pages/infinite-canvas/navigation/ShotList.tsx create mode 100644 src/pages/infinite-canvas/navigation/index.ts create mode 100644 src/pages/infinite-canvas/store/useCanvasStore.ts create mode 100644 src/pages/infinite-canvas/toolbar/CanvasFilterBar.tsx create mode 100644 src/pages/infinite-canvas/toolbar/CanvasToolbar.tsx create mode 100644 src/pages/infinite-canvas/toolbar/StageTabs.tsx create mode 100644 src/pages/infinite-canvas/toolbar/index.ts create mode 100644 src/pages/infinite-canvas/utils/ICanvasDataSource.ts create mode 100644 src/pages/infinite-canvas/utils/LocalFileDataSource.ts create mode 100644 src/pages/infinite-canvas/utils/constants.ts create mode 100644 src/pages/infinite-canvas/utils/index.ts create mode 100644 src/pages/infinite-canvas/utils/layout.ts create mode 100644 src/pages/infinite-canvas/utils/scanner.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e6101c4..a3005a5 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -21,6 +21,18 @@ declare namespace NodeJS { } } +/** sharp 图片处理库(可选依赖,运行时动态加载) */ +declare module 'sharp' { + interface SharpInstance { + metadata(): Promise<{ width?: number; height?: number; format?: string }>; + resize(width?: number, height?: number, options?: { fit?: string; withoutEnlargement?: boolean }): SharpInstance; + jpeg(options?: { quality?: number }): SharpInstance; + toBuffer(): Promise; + } + function sharp(input: string | Buffer): SharpInstance; + export = sharp; +} + // Used in Renderer process, expose in `preload.ts` declare interface Window { ipcRenderer: import('electron').IpcRenderer; @@ -34,4 +46,41 @@ declare interface Window { /** 登录/登出后更新窗口标题中的版控后缀 */ setWindowEdition(edition: string | null): void; }; + /** 无限画布 API(由 preload 注入) */ + canvas: { + /** 打开画布窗口 */ + openWindow(projectPath?: string): void; + /** 扫描目录 */ + 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 }>; + /** 读取 sidecar 元数据 */ + readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }>; + /** 写入 sidecar 元数据 */ + writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }>; + /** 生成缩略图 */ + generateThumbnail(params: { filePath: string; size: number }): Promise<{ + success: boolean; + data: { data: string; mimeType: string; width: number; height: number }; + error?: string; + }>; + /** 扫描项目 */ + 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 }>; + /** 复制文件 */ + copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }>; + /** 列出目录下的子文件夹(非递归,排除隐藏) */ + listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }>; + /** 扫描阶段文件(单个 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 }>; + /** 扫描资产文件(深度递归扫描 assets/ 目录) */ + scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>; + /** 创建目录 */ + createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }>; + /** 将本地文件路径转换为 local-media:// 协议 URL */ + getMediaUrl(filePath: string): string; + /** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */ + startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }>; + }; } diff --git a/electron/main.ts b/electron/main.ts index 22e14ae..007fe53 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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('//')) { + // UNC:file://server/share/path(authority = server) + return net.fetch('file:' + fwd); + } + // 本地:file:///C:/path/to/file(authority 为空) + 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(); diff --git a/electron/main/ipc/canvas-ipc.ts b/electron/main/ipc/canvas-ipc.ts new file mode 100644 index 0000000..0154a7c --- /dev/null +++ b/electron/main/ipc/canvas-ipc.ts @@ -0,0 +1,715 @@ +// ============================================================ +// canvas-ipc.ts — 无限画布 IPC 处理器(主进程) +// +// 职责: +// - 目录扫描(fs.readdirSync + 递归遍历) +// - sidecar JSON 读写 +// - 缩略图生成(sharp 可选依赖) +// - 文件复制(导入操作) +// ============================================================ + +import { ipcMain } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels'; +import { logger } from '../logger'; + +// ---------- 类型 ---------- + +type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets'; +type AssetTypeFilter = '全部' | '角色' | '道具' | '场景'; + +// ---------- 扫描算法 ---------- + +/** 递归遍历目录,收集所有匹配扩展名的文件 */ +function walkDirectory( + root: string, + extensions: string[], + recursive: boolean, +): Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> { + const results: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> = []; + const extSet = new Set(extensions.map((e) => e.toLowerCase())); + + function walk(dir: string) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; // 权限不足等,跳过该目录 + } + + for (const entry of entries) { + // 跳过隐藏文件和文件夹 + if (entry.name.startsWith('.')) continue; + + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (recursive) walk(fullPath); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).slice(1).toLowerCase(); + if (extSet.has(ext)) { + try { + const stat = fs.statSync(fullPath); + results.push({ + path: fullPath, + fileName: entry.name, + ext, + fileSize: stat.size, + modifiedAt: stat.mtime.toISOString(), + }); + } catch { + // 文件不可访问,跳过 + } + } + } + } + } + + walk(root); + return results; +} + +// ---------- Sidecar 操作 ---------- + +/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */ +function sidecarPathForMedia(filePath: string): string { + const dir = path.dirname(filePath); + const baseName = path.basename(filePath, path.extname(filePath)); + return path.join(dir, `${baseName}.json`); +} + +/** 读取旁加载元数据,文件不存在返回 null */ +function readSidecarFile(filePath: string): object | null { + const sidecarPath = sidecarPathForMedia(filePath); + if (!fs.existsSync(sidecarPath)) return null; + try { + const content = fs.readFileSync(sidecarPath, 'utf-8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** 写入旁加载元数据(自动创建目录) */ +function writeSidecarFile(filePath: string, metadata: object): void { + const sidecarPath = sidecarPathForMedia(filePath); + const dir = path.dirname(sidecarPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8'); +} + +// ---------- MIME 类型推断 ---------- + +function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const mimeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.flac': 'audio/flac', + '.aac': 'audio/aac', + '.m4a': 'audio/mp4', + }; + return mimeMap[ext] || 'application/octet-stream'; +} + +// ---------- 缩略图生成 ---------- + +/** + * 尝试使用 sharp 库生成缩略图。 + * + * 优先级: + * 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快) + * 2. .cache/{filename} — 预生成 PNG 缩略图 + * 3. sharp — 实时缩放(可选依赖) + * 4. 原始文件字节 — 浏览器端缩放回退 + */ +async function generateThumbnailImpl( + filePath: string, + size: number, +): Promise<{ data: string; mimeType: string; width: number; height: number }> { + const dir = path.dirname(filePath); + const baseName = path.basename(filePath); + const cacheDir = path.join(dir, '.cache'); + + // ── 优先级 1:.cache/{filename}.webp ── + const cachedWebp = path.join(cacheDir, `${baseName}.webp`); + if (fs.existsSync(cachedWebp)) { + const buffer = fs.readFileSync(cachedWebp); + return { + data: buffer.toString('base64'), + mimeType: 'image/webp', + width: 0, + height: 0, + }; + } + + // ── 优先级 2:.cache/{filename}(PNG 副本) ── + const cachedPng = path.join(cacheDir, baseName); + if (fs.existsSync(cachedPng)) { + const buffer = fs.readFileSync(cachedPng); + return { + data: buffer.toString('base64'), + mimeType: 'image/png', + width: 0, + height: 0, + }; + } + + // ── 优先级 3:sharp 实时缩放 ── + let sharp: typeof import('sharp') | null = null; + try { + sharp = require('sharp'); + } catch { + /* sharp 未安装 */ + } + + if (sharp) { + const image = sharp(filePath); + const metadata = await image.metadata(); + const resized = await image + .resize(size, size, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + return { + data: resized.toString('base64'), + mimeType: 'image/jpeg', + width: metadata.width || 0, + height: metadata.height || 0, + }; + } + + // ── 优先级 4:回退原始文件 ── + const buffer = fs.readFileSync(filePath); + const mimeType = getMimeType(filePath); + return { + data: buffer.toString('base64'), + mimeType, + width: 0, + height: 0, + }; +} + +// ---------- 扫描根目录计算(移自主进程,因渲染进程沙箱无 fs 权限) ---------- + +/** 阶段 → 允许的扩展名 */ +function getStageExtensions(stage: StageType): string[] { + switch (stage) { + case 'Storyboard': + case 'Keyframe': + return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif']; + case 'Video': + return ['mp4', 'webm', 'mov', 'avi', 'mkv']; + case 'Audio': + case 'LipSync': + return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']; + case 'Assets': + return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm']; + } +} + +/** 镜头名匹配查询 */ +function shotNameMatchesQuery(shotName: string, query: string): boolean { + if (!query) return true; + const upperShot = shotName.toUpperCase(); + const upperQuery = query.toUpperCase(); + if (upperShot.includes(upperQuery)) return true; + if (/^\d+$/.test(query)) { + const digits = upperShot.replace(/\D/g, ''); + if (digits.includes(query)) return true; + } + return false; +} + +/** 计算扫描根目录列表 */ +function buildScanRoots(params: { + projectPath: string; + stage: StageType; + assetTypeFilter: AssetTypeFilter; + shotQuery: string; +}): string[] { + const { projectPath, stage, assetTypeFilter, shotQuery } = params; + + // ── 诊断:检查 projectPath 本身是否存在 ── + const projectExists = fs.existsSync(projectPath); + logger.info('canvas', 'buildScanRoots 诊断 — projectPath', { + projectPath, + exists: projectExists, + }); + + if (!projectExists) { + logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath }); + // 尝试列出父目录内容以便排查 + const parent = path.dirname(projectPath); + try { + const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.')); + logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, { + count: siblings.length, + items: siblings.slice(0, 20), + }); + } catch { + logger.warn('canvas', `无法读取父目录 "${parent}"`); + } + return []; + } + + // ── 诊断:列出项目根目录内容 ── + let projectEntries: string[] = []; + try { + projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.')); + } catch { + /* ignore */ + } + logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', { + count: projectEntries.length, + items: projectEntries.slice(0, 30), + }); + + if (stage === 'Assets') { + const assetsDir = path.join(projectPath, 'assets'); + logger.info('canvas', 'buildScanRoots — Assets 模式', { + assetsDir, + exists: fs.existsSync(assetsDir), + assetTypeFilter, + }); + switch (assetTypeFilter) { + case '角色': return [path.join(assetsDir, 'chr')]; + case '道具': return [path.join(assetsDir, 'prp')]; + case '场景': return [path.join(assetsDir, 'set')]; + default: return [assetsDir]; + } + } + + const shotsDir = path.join(projectPath, 'shots'); + const shotsExists = fs.existsSync(shotsDir); + logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', { + shotsDir, + exists: shotsExists, + }); + + if (!shotsExists) { + // 列出可能存在的子目录帮助用户对比 + const dirsInRoot = projectEntries.filter((name) => { + try { + return fs.statSync(path.join(projectPath, name)).isDirectory(); + } catch { + return false; + } + }); + logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, { + subdirs: dirsInRoot, + }); + return []; + } + + const roots: string[] = []; + let episodes: string[]; + try { + episodes = fs.readdirSync(shotsDir).filter((name) => { + const full = path.join(shotsDir, name); + return !name.startsWith('.') && fs.statSync(full).isDirectory(); + }); + } catch (err) { + logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, { + error: (err as Error).message, + }); + return []; + } + + logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, { + episodes, + }); + + for (const ep of episodes) { + const epDir = path.join(shotsDir, ep); + let shotNames: string[]; + try { + shotNames = fs.readdirSync(epDir).filter((name) => { + const full = path.join(epDir, name); + return !name.startsWith('.') && fs.statSync(full).isDirectory(); + }); + } catch (err) { + logger.warn( + 'canvas', + `无法读取剧集目录 "${ep}"`, + err instanceof Error ? err : undefined, + { error: (err as Error).message }, + ); + continue; + } + + logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, { + shots: shotNames.slice(0, 20), + }); + + for (const shot of shotNames) { + if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue; + const stageDir = path.join(epDir, shot, stage); + const stageExists = fs.existsSync(stageDir); + if (stageExists) { + roots.push(stageDir); + } + // 仅在前几个镜头时记录阶段目录存在性 + if (roots.length <= 3 && !stageExists) { + logger.info('canvas', ` 阶段目录不存在: ${stageDir}`); + } + } + } + + logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, { + roots: roots.slice(0, 10), + stage, + }); + + return roots; +} + +// ---------- 注册 ---------- + +export function registerCanvasIpcHandlers(): void { + // ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_SCAN_PROJECT, + async ( + _event, + params: { + projectPath: string; + stage: StageType; + assetTypeFilter: AssetTypeFilter; + shotQuery: string; + }, + ) => { + try { + logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params); + + const roots = buildScanRoots(params); + const extensions = getStageExtensions(params.stage); + + logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, { + roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`), + extensions, + stage: params.stage, + }); + + if (roots.length === 0) { + logger.warn('canvas', '扫描根目录为空', undefined, { + projectPath: params.projectPath, + stage: params.stage, + shotQuery: params.shotQuery || '(无)', + }); + return { success: true, data: [] }; + } + + const allResults: Array<{ + path: string; + fileName: string; + ext: string; + fileSize: number; + modifiedAt: string; + }> = []; + + for (const root of roots) { + const results = walkDirectory(root, extensions, true); + if (results.length > 0) { + logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`); + } + allResults.push(...results); + } + + logger.info( + 'canvas', + `扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`, + ); + return { success: true, data: allResults }; + } catch (err) { + const msg = (err as Error).message; + logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined); + return { success: false, error: msg }; + } + }, + ); + + // ---- 目录扫描(保留低级 API:外部提供扫描根) ---- + // ---- 目录扫描 ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, + async ( + _event, + params: { roots: string[]; extensions: string[]; recursive: boolean }, + ) => { + try { + const allResults: Array<{ + path: string; + fileName: string; + ext: string; + fileSize: number; + modifiedAt: string; + }> = []; + for (const root of params.roots) { + if (!fs.existsSync(root)) { + logger.warn('canvas', `扫描根目录不存在,跳过:${root}`); + continue; + } + const results = walkDirectory(root, params.extensions, params.recursive); + allResults.push(...results); + } + logger.info( + 'canvas', + `扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`, + ); + return { success: true, data: allResults }; + } catch (err) { + const msg = (err as Error).message; + logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined); + return { success: false, error: msg }; + } + }, + ); + + // ---- 读取 sidecar ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_READ_SIDECAR, + async (_event, params: { filePath: string }) => { + try { + const data = readSidecarFile(params.filePath); + return { success: true, data }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 写入 sidecar ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, + async (_event, params: { filePath: string; metadata: object }) => { + try { + writeSidecarFile(params.filePath, params.metadata); + return { success: true }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 生成缩略图 ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, + async (_event, params: { filePath: string; size: number }) => { + try { + const result = await generateThumbnailImpl(params.filePath, params.size); + return { success: true, data: result }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 复制文件 ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_COPY_FILE, + async (_event, params: { src: string; destDir: string; fileName: string }) => { + try { + if (!fs.existsSync(params.destDir)) { + fs.mkdirSync(params.destDir, { recursive: true }); + } + const dest = path.join(params.destDir, params.fileName); + fs.copyFileSync(params.src, dest); + return { success: true, data: dest }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 获取图片尺寸(使用 sharp 或 image-size) ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE, + async (_event, params: { filePath: string }) => { + try { + // 尝试 sharp + let sharp: typeof import('sharp') | null = null; + try { + sharp = require('sharp'); + } catch { + /* 未安装 */ + } + if (sharp) { + const metadata = await sharp(params.filePath).metadata(); + return { + success: true, + data: { + width: metadata.width || 0, + height: metadata.height || 0, + }, + }; + } + return { success: false, error: 'sharp 未安装,无法获取图片尺寸' }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 列出子文件夹(非递归,排除隐藏) ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, + async (_event, params: { parentPath: string }) => { + try { + if (!fs.existsSync(params.parentPath)) { + logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`); + return { success: true, data: [] }; + } + const entries = fs.readdirSync(params.parentPath, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory() && !e.name.startsWith('.')) + .map((e) => e.name); + logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`); + return { success: true, data: dirs }; + } catch (err) { + logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 扫描阶段文件 ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, + async ( + _event, + params: { + projectPath: string; + episode: string; + shot: string; + stage: string; + }, + ) => { + try { + const stageDir = path.join( + params.projectPath, + 'shots', + params.episode, + params.shot, + params.stage, + ); + logger.info('canvas', `scanStageFiles: ${stageDir}`); + if (!fs.existsSync(stageDir)) { + logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`); + return { success: true, data: [] }; + } + const extensions = getStageExtensions(params.stage as StageType); + const results = walkDirectory(stageDir, extensions, true); + logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`); + return { success: true, data: results }; + } catch (err) { + logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 扫描资产文件(深度递归) ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, + async ( + _event, + params: { projectPath: string; assetTypeFilter: string }, + ) => { + try { + let assetsDir: string; + switch (params.assetTypeFilter) { + case '角色': + assetsDir = path.join(params.projectPath, 'assets', 'chr'); + break; + case '道具': + assetsDir = path.join(params.projectPath, 'assets', 'prp'); + break; + case '场景': + assetsDir = path.join(params.projectPath, 'assets', 'set'); + break; + default: + assetsDir = path.join(params.projectPath, 'assets'); + } + logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`); + if (!fs.existsSync(assetsDir)) { + logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`); + return { success: true, data: [] }; + } + // 资产深度递归:图片 + 视频 + const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm']; + const results = walkDirectory(assetsDir, extensions, true); + logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`); + return { success: true, data: results }; + } catch (err) { + logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 创建目录 ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, + async (_event, params: { parentPath: string; dirName: string }) => { + try { + // 安全校验:拒绝包含危险字符的名称 + const dangerous = /[/\\:*?"<>|]/; + if (!params.dirName || dangerous.test(params.dirName)) { + return { + success: false, + error: `无效的目录名: "${params.dirName}"`, + }; + } + const fullPath = path.join(params.parentPath, params.dirName); + if (fs.existsSync(fullPath)) { + return { success: false, error: `目录已存在: ${fullPath}` }; + } + fs.mkdirSync(fullPath); + logger.info('canvas', `createDirectory: ${fullPath}`); + return { success: true, data: fullPath }; + } catch (err) { + logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; + } + }, + ); + + // ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ---- + ipcMain.handle( + BIDIRECTIONAL.CANVAS_START_DRAG, + async (_event, params: { filePath: string; fileName: string }) => { + try { + if (!fs.existsSync(params.filePath)) { + logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`); + return { success: false, error: '文件不存在' }; + } + _event.sender.startDrag({ + file: params.filePath, + icon: params.filePath, + }); + logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`); + return { success: true }; + } catch (err) { + const msg = (err as Error).message; + logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined); + return { success: false, error: msg }; + } + }, + ); + + logger.info('canvas', '画布 IPC 处理器已注册(完整实现)'); +} diff --git a/electron/preload.ts b/electron/preload.ts index 8dfed5e..67e5117 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -88,7 +88,7 @@ contextBridge.exposeInMainWorld('fileStorage', { }, }); -// --------- 应用运行时 API --------- +/** 应用运行时 API */ contextBridge.exposeInMainWorld('appRuntime', { /** * 登录/登出后更新窗口标题中的版控后缀 @@ -98,3 +98,80 @@ contextBridge.exposeInMainWorld('appRuntime', { 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:/// 格式而非 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); + }, +}); diff --git a/shared/constants/ipc-channels.ts b/shared/constants/ipc-channels.ts index 74b856f..5b05f3f 100644 --- a/shared/constants/ipc-channels.ts +++ b/shared/constants/ipc-channels.ts @@ -12,6 +12,10 @@ export const MAIN_TO_RENDERER = { WINDOW_FOCUSED: 'window-focused', /** 窗口失焦通知 */ WINDOW_BLURRED: 'window-blurred', + /** 画布窗口准备就绪 */ + CANVAS_READY: 'canvas:ready', + /** 画布扫描进度 */ + CANVAS_SCAN_PROGRESS: 'canvas:scan-progress', } as const; /** 渲染进程 → 主进程 */ @@ -32,6 +36,8 @@ export const RENDERER_TO_MAIN = { CLOSE_WINDOW: 'close-window', /** 渲染进程日志上报 */ LOG_MESSAGE: 'log-message', + /** 画布:打开无限画布窗口 */ + OPEN_CANVAS_WINDOW: 'canvas:open-window', } as const; /** 双向通信通道 */ @@ -62,4 +68,28 @@ export const BIDIRECTIONAL = { FILE_SIZE: 'storage:file-size', /** 文件/目录是否存在 */ FILE_EXISTS: 'storage:file-exists', + /** 画布:扫描目录 */ + CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory', + /** 画布:扫描项目(接收 projectPath,主进程内部完成 buildScanRoots + walkDirectory) */ + CANVAS_SCAN_PROJECT: 'canvas:scan-project', + /** 画布:读取 sidecar 元数据 */ + CANVAS_READ_SIDECAR: 'canvas:read-sidecar', + /** 画布:写入 sidecar 元数据 */ + CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar', + /** 画布:生成缩略图 */ + CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail', + /** 画布:复制文件(导入) */ + CANVAS_COPY_FILE: 'canvas:copy-file', + /** 画布:获取原生图片尺寸 */ + CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size', + /** 画布:列出目录下的子文件夹(非递归,排除隐藏) */ + CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs', + /** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */ + CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files', + /** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */ + CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files', + /** 画布:创建目录 */ + CANVAS_CREATE_DIRECTORY: 'canvas:create-directory', + /** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */ + CANVAS_START_DRAG: 'canvas:start-drag', } as const; diff --git a/src/components/frame/navbar/NavBar.tsx b/src/components/frame/navbar/NavBar.tsx index 62a3849..b75b83f 100644 --- a/src/components/frame/navbar/NavBar.tsx +++ b/src/components/frame/navbar/NavBar.tsx @@ -12,7 +12,8 @@ import {App} from 'antd'; import {useAppContext} from '@/components/providers/AppProvider'; import {useTheme} from '@/components/providers/ThemeProvider'; -import {emit, EVENTS} from '@/utils/bus'; +import {emit, EVENTS, safeIpcSend} from '@/utils/bus'; +import {RENDERER_TO_MAIN} from '@shared/constants/ipc-channels'; import {NavItem} from './NavItem'; import {AnnouncementDrawer} from './AnnouncementDrawer'; import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config'; @@ -91,6 +92,12 @@ export function NavBar() { break; case 'page': + // 项目管理 → 打开独立画布窗口(新 BrowserWindow,非主窗口内叠加层) + if (item.key === 'project-management') { + console.log('[NavBar] 🖼️ 点击"项目管理" → 发送 OPEN_CANVAS_WINDOW IPC'); + safeIpcSend(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW); + return; + } if (item.target) { emit(EVENTS.OPEN_PAGE, { target: item.target, diff --git a/src/main.tsx b/src/main.tsx index 23e6aaa..feb6a0b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,24 +6,34 @@ import App from './App'; import {ThemeProvider} from './components/providers/ThemeProvider'; import {AppProvider} from './components/providers/AppProvider'; import {SettingsProvider} from './components/providers/SettingsProvider'; +import CanvasApp from './pages/infinite-canvas/CanvasApp'; // 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置) import './theme/globals.css'; +/** 判断当前是否为画布窗口(通过 URL hash 区分) */ +const isCanvasMode = window.location.hash.startsWith('#/canvas'); + ReactDOM.createRoot(document.getElementById('root')!).render( - {/* AppProvider — 全局应用状态(平台/版本/登录态) */} - - {/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */} + {isCanvasMode ? ( + // Canvas 模式:轻量级 Provider,无 Auth/Storage/Settings 依赖 - {/* SettingsProvider — 集中管理设置项(存储路径等) */} - - {/* AntdApp 提供 message / notification / modal 的静态方法上下文 */} - - - - + + + - + ) : ( + // 正常模式:完整的 AppProvider 链 + + + + + + + + + + )} , ); diff --git a/src/pages/infinite-canvas/CanvasApp.tsx b/src/pages/infinite-canvas/CanvasApp.tsx new file mode 100644 index 0000000..bd91a84 --- /dev/null +++ b/src/pages/infinite-canvas/CanvasApp.tsx @@ -0,0 +1,93 @@ +// ============================================================ +// CanvasApp — 无限画布根组件 +// +// 通过 hash 路由 #/canvas 进入的轻量级入口。 +// 不加载 AppProvider / Auth / Storage / Settings,与主应用解耦。 +// +// 工作区路径: +// 优先读取设置中的"团队创作存储仓库路径"(localStorage key: ele-heixiu-team-repo-path), +// 未设置时由用户在画布工具栏中手动选择。 +// ============================================================ + +import { useMemo, useEffect } from 'react'; +import { LocalFileDataSource } from './utils/LocalFileDataSource'; +import { createCanvasStoreProvider, useCanvasStore } from './store/useCanvasStore'; +import { CanvasPage } from './CanvasPage'; + +/** localStorage 键名 —— 与 SettingsProvider 保持一致 */ +const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path'; + +/** 从 localStorage 读取已保存的团队创作仓库路径 */ +function readStoredTeamRepoPath(): string | null { + try { + const stored = localStorage.getItem(TEAM_REPO_PATH_KEY); + return stored?.trim() || null; + } catch { + return null; + } +} + +/** + * 无限画布根组件。 + * + * 职责: + * 1. 创建 LocalFileDataSource(离线模式)数据源实例 + * 2. 通过工厂函数创建 Zustand Store + Provider + * 3. 自动填充设置中保存的工作区路径 + * 4. 初始化后自动加载项目列表 + * 5. 渲染 CanvasPage(全屏画布 UI) + * + * 切换在线模式: + * 将 `new LocalFileDataSource()` 替换为 `new RemoteDataSource(projectId, wsUrl)` + * Provider 和 Page 层零改动。 + */ +export default function CanvasApp() { + const { CanvasStoreProvider } = useMemo(() => { + const dataSource = new LocalFileDataSource(); + const initialWorkspace = readStoredTeamRepoPath(); + + console.log('[CanvasApp] 🎨 初始化无限画布', { + dataSourceType: dataSource.constructor.name, + capabilities: dataSource.capabilities, + initialWorkspace: initialWorkspace || '(未设置 — 需在工具栏手动选择)', + hash: window.location.hash, + userAgent: navigator.userAgent.includes('Electron') ? 'Electron' : 'Browser', + }); + + return createCanvasStoreProvider(dataSource, initialWorkspace); + }, []); + + return ( + + + + + ); +} + +/** + * 内部组件:工作区初始化后自动加载项目列表。 + * 放在 Provider 内部以访问 store。 + */ +function WorkspaceInitializer() { + const workspaceRoot = useCanvasStore((s) => s.workspaceRoot); + const setProjects = useCanvasStore((s) => s.setProjects); + const getDataSource = useCanvasStore((s) => s.getDataSource); + + useEffect(() => { + if (!workspaceRoot) return; + const dataSource = getDataSource(); + console.log('[CanvasApp] 📋 加载项目列表:', workspaceRoot); + dataSource + .listSubdirs(workspaceRoot) + .then((projects) => { + console.log('[CanvasApp] ✅ 项目列表:', projects); + setProjects(projects); + }) + .catch((err) => { + console.error('[CanvasApp] ❌ 加载项目列表失败:', err); + }); + }, [workspaceRoot, setProjects, getDataSource]); + + return null; +} diff --git a/src/pages/infinite-canvas/CanvasPage.tsx b/src/pages/infinite-canvas/CanvasPage.tsx new file mode 100644 index 0000000..b7deab0 --- /dev/null +++ b/src/pages/infinite-canvas/CanvasPage.tsx @@ -0,0 +1,225 @@ +// ============================================================ +// CanvasPage — 无限画布页面 +// +// 两栏 flex 布局(Assets 阶段为单栏): +// 左侧:ShotList(镜头单选列表) +// 右侧:CanvasViewport(主画布) +// 顶部:CanvasToolbar +// 底部:状态栏 +// ============================================================ + +import { useTheme } from '@/components/providers/ThemeProvider'; +import { useCanvasStore } from './store/useCanvasStore'; +import { useDirectoryScan } from './hooks/useDirectoryScan'; +import { CanvasToolbar } from './toolbar/CanvasToolbar'; +import { CanvasViewport } from './core/CanvasViewport'; +import { ShotList } from './navigation/ShotList'; + +/** 中心区域占位提示 */ +function Placeholder({ + icon, + title, + hint, + isDark, +}: { + icon: string; + title: string; + hint: string; + isDark: boolean; +}) { + return ( +
+
{icon}
+
{title}
+
+ {hint} +
+
+ ); +} + +export function CanvasPage() { + const { isDark } = useTheme(); + const workspaceRoot = useCanvasStore((s) => s.workspaceRoot); + const selectedProject = useCanvasStore((s) => s.selectedProject); + const selectedEpisode = useCanvasStore((s) => s.selectedEpisode); + const selectedShot = useCanvasStore((s) => s.selectedShot); + const items = useCanvasStore((s) => s.items); + const isScanning = useCanvasStore((s) => s.isScanning); + const currentStage = useCanvasStore((s) => s.currentStage); + + // 启动目录扫描监听 + useDirectoryScan(); + + const isAssets = currentStage === 'Assets'; + + /** 根据当前选中状态决定主视图内容 */ + const renderMainContent = () => { + if (!workspaceRoot) { + return ( + + ); + } + + if (!selectedProject) { + return ( + + ); + } + + if (isScanning) { + return ( +
+
🔍
+
正在扫描文件…
+
+ ); + } + + if (!isAssets && !selectedEpisode) { + return ( + + ); + } + + if (!isAssets && !selectedShot) { + return ( + + ); + } + + if (items.length === 0) { + return ( + + ); + } + + return ; + }; + + return ( +
+ {/* 顶部工具栏 */} + + + {/* 中心区域:两栏布局 */} +
+ {/* 左侧镜头列表(Assets 阶段隐藏) */} + + + {/* 右侧主内容 */} +
+ {renderMainContent()} +
+
+ + {/* 底部状态栏 */} +
+ 离线模式 · 本地文件系统 + | + + {isScanning + ? '扫描中…' + : workspaceRoot + ? `项目:${selectedProject || '(未选)'} · 阶段:${currentStage} · ${items.length} 个文件` + : '准备就绪'} + + {selectedEpisode && ( + <> + | + 集数:{selectedEpisode} + + )} + {selectedShot && ( + <> + | + 镜头:{selectedShot} + + )} + + Phase 1 +
+
+ ); +} diff --git a/src/pages/infinite-canvas/core/CanvasViewport.tsx b/src/pages/infinite-canvas/core/CanvasViewport.tsx new file mode 100644 index 0000000..39f8d7f --- /dev/null +++ b/src/pages/infinite-canvas/core/CanvasViewport.tsx @@ -0,0 +1,367 @@ +// ============================================================ +// CanvasViewport — 核心画布组件 +// +// CSS Transform 虚拟视口: +// - Grid 模式:固定列数,均匀网格布局 +// - Single 模式:按 groupKey 分组,每组一行横向排列 +// - 滚轮缩放:滚轮直接调整 zoom(无需修饰键),Ctrl/Cmd + 滚轮精细缩放 +// - 鼠标拖拽:mousedown + mousemove 平移(panOffset) +// - 分页叠加层:PagerOverlay + ZoomOverlay +// ============================================================ + +import { useRef, useCallback, useEffect, useState, useMemo } from 'react'; +import { useTheme } from '@/components/providers/ThemeProvider'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { MediaCard } from './MediaCard'; +import { MediaCardContextMenu } from './MediaCardContextMenu'; +import { MediaPreview } from './MediaPreview'; +import { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls'; +import { + gridPosition, + gridPageInfo, + gridCanvasSize, + singleLayout, + singlePageInfo, +} from '../utils/layout'; +import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../utils/constants'; + +/** 缩放倍率范围 */ +const ZOOM_MIN = 0.02; +const ZOOM_MAX = 16.0; + +export function CanvasViewport() { + const { isDark } = useTheme(); + const items = useCanvasStore((s) => s.items); + const layoutMode = useCanvasStore((s) => s.layoutMode); + const cols = useCanvasStore((s) => s.cols); + const rowsPerPage = useCanvasStore((s) => s.rowsPerPage); + const singleRowsPerPage = useCanvasStore((s) => s.singleRowsPerPage); + const currentPage = useCanvasStore((s) => s.currentPage); + const zoom = useCanvasStore((s) => s.zoom); + const panOffset = useCanvasStore((s) => s.panOffset); + const interactionMode = useCanvasStore((s) => s.interactionMode); + const setZoom = useCanvasStore((s) => s.setZoom); + const setPanOffset = useCanvasStore((s) => s.setPanOffset); + + // 拖拽状态 + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); + const containerRef = useRef(null); + + // 预览打开时锁定视口事件(防止穿透缩放/拖拽画布) + const previewActiveRef = useRef(false); + + // ref 版 setZoom + currentZoom — 供原生 wheel handler 稳定读写。 + // 避免 Zustand v5 中 getState() 在连续原生事件中返回批处理前的旧值, + // 导致每次滚轮都从同一个起点计算(日志反复出现"100% → 90%")。 + const setZoomRef = useRef(setZoom); + useEffect(() => { setZoomRef.current = setZoom; }, [setZoom]); + const currentZoomRef = useRef(zoom); + useEffect(() => { currentZoomRef.current = zoom; }, [zoom]); + + // 全屏预览状态 + const [previewItemId, setPreviewItemId] = useState(null); + + const handleDoubleClick = useCallback((itemId: number) => { + setPreviewItemId(itemId); + }, []); + + const handleClosePreview = useCallback(() => { + setPreviewItemId(null); + }, []); + + // 同步预览状态到 ref(供原生 wheel 监听器读取,避免闭包过期) + useEffect(() => { + previewActiveRef.current = previewItemId !== null; + }, [previewItemId]); + + // ── 分页计算 ── + + const pageItems = useMemo(() => { + if (items.length === 0) return []; + if (layoutMode === 'grid') { + const { startIndex, endIndex } = gridPageInfo( + items.length, + cols, + rowsPerPage, + currentPage, + ); + return items.slice(startIndex, endIndex); + } + // single mode + const { startIndex, endIndex } = singlePageInfo( + items, + singleRowsPerPage, + currentPage, + ); + return items.slice(startIndex, endIndex); + }, [items, layoutMode, cols, rowsPerPage, singleRowsPerPage, currentPage]); + + // ── 位置计算 ── + + const { positions, canvasWidth, canvasHeight, rowLabels } = useMemo(() => { + if (layoutMode === 'grid') { + const positions = new Map(); + pageItems.forEach((item, idx) => { + positions.set(item.id, gridPosition(idx, cols)); + }); + const size = gridCanvasSize(pageItems.length, cols); + return { + positions, + canvasWidth: Math.max(size.width, 400), + canvasHeight: Math.max(size.height, 300), + rowLabels: new Map(), + }; + } + // single mode + const layout = singleLayout(pageItems); + // 构建行标签映射 + const labels = new Map(); + const seen = new Set(); + let row = 0; + for (const item of pageItems) { + if (!seen.has(item.groupKey)) { + seen.add(item.groupKey); + labels.set(row, item.groupKey); + row++; + } + } + return { + positions: layout.positions, + canvasWidth: Math.max(layout.canvasWidth, 400), + canvasHeight: Math.max(layout.canvasHeight, 300), + rowLabels: labels, + }; + }, [layoutMode, pageItems, cols]); + + // ── 滚轮缩放 ── + // 纯滚轮 → 快速缩放(10%/tick),Ctrl/Cmd + 滚轮 → 精细缩放(3%/tick)。 + // Mac 触控板捏合手势 → macOS 自动合成 Ctrl+wheel 事件 → 精细缩放。 + // + // 使用 window 级原生监听器({ passive: false })而非 React onWheel, + // 因为 React 18+ / Electron 42 中 React 合成事件一律为 passive,preventDefault 会抛异常。 + // + // ⚠️ 通过 ref(setZoomRef + currentZoomRef)读写 zoom, + // 避免 Zustand v5 中 getState() 在原生事件循环中返回批处理前的旧值。 + + useEffect(() => { + const handler = (e: WheelEvent) => { + // 仅处理容器内部的滚轮事件(避免 ShotList 等其他区域触发) + if (!containerRef.current?.contains(e.target as Node)) return; + // 预览打开时锁定视口,防止穿透缩放 + if (previewActiveRef.current) return; + try { + e.preventDefault(); + } catch (err) { + console.error('[CanvasViewport] ⚠️ preventDefault 失败(passive 冲突):', err); + return; + } + // 从 ref 读取当前 zoom(避免 Zustand v5 getState() 在原生事件中返回旧值) + const currentZoom = currentZoomRef.current; + // Ctrl/Cmd → 精细缩放(3%/tick),纯滚轮 → 快速缩放(10%/tick) + const sensitivity = (e.ctrlKey || e.metaKey) ? 0.0003 : 0.001; + const delta = -e.deltaY * sensitivity; + const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, currentZoom + delta)); + // 同步更新 ref(供下一帧滚轮事件读取最新值) + currentZoomRef.current = newZoom; + setZoomRef.current(newZoom); + }; + window.addEventListener('wheel', handler, { passive: false }); + return () => window.removeEventListener('wheel', handler); + }, []); + + // ── 拖拽平移 ── + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // 预览打开时锁定视口,防止穿透拖拽 + if (previewActiveRef.current) return; + // 仅鼠标左键 + 非交互子元素(如按钮) + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target.closest('button')) return; + setIsDragging(true); + dragStart.current = { + x: e.clientX, + y: e.clientY, + panX: panOffset.x, + panY: panOffset.y, + }; + }, + [panOffset], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isDragging) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setPanOffset({ + x: dragStart.current.panX + dx, + y: dragStart.current.panY + dy, + }); + }, + [isDragging, setPanOffset], + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + // 全局 mouseup(拖拽到视口外时释放) + useEffect(() => { + const handler = () => setIsDragging(false); + window.addEventListener('mouseup', handler); + return () => window.removeEventListener('mouseup', handler); + }, []); + + // ── 键盘快捷键 ── + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // 缩放重置 + if ((e.ctrlKey || e.metaKey) && e.key === '0') { + e.preventDefault(); + setZoom(1.0); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [setZoom]); + + // ── 渲染 ── + + const transform = `scale(${zoom}) translate(${panOffset.x}px, ${panOffset.y}px)`; + + return ( +
+ {/* 画布内容 */} +
+ {/* Single 模式的行标签 */} + {layoutMode === 'single' && + Array.from(rowLabels.entries()).map(([row, label]) => ( +
+ {label} +
+ ))} + + {/* MediaCard 渲染(受硬上限保护) */} + {pageItems.slice(0, MAX_ITEMS_PER_PAGE).map((item) => { + const pos = positions.get(item.id); + if (!pos) return null; + return ( + + + + ); + })} + + {/* DOM 硬上限警告 */} + {pageItems.length > MAX_ITEMS_PER_PAGE && ( +
+ ⚠️ 当前页共 {pageItems.length} 个文件,已渲染前 {MAX_ITEMS_PER_PAGE} 个。 + 请切换为更小的页面尺寸或使用筛选。 +
+ )} + + {/* 空画布提示 */} + {pageItems.length === 0 && ( +
+ 无匹配文件 +
+ )} +
+ + {/* 浮层控件 */} + + + + + {/* 全屏预览(双击打开) */} + +
+ ); +} diff --git a/src/pages/infinite-canvas/core/LODImage.tsx b/src/pages/infinite-canvas/core/LODImage.tsx new file mode 100644 index 0000000..5319429 --- /dev/null +++ b/src/pages/infinite-canvas/core/LODImage.tsx @@ -0,0 +1,144 @@ +// ============================================================ +// LODImage — 多级清晰度图片组件 +// +// 优先级: +// 1. 已缓存的 Blob URL(Store 中 item.blobUrl) +// 2. dataSource.generateThumbnail() → base64 data URL +// 3. 棋盘格背景占位(加载前) +// +// 图片加载完成后通过 CanvasStore.loadThumbnail 缓存 URL。 +// ============================================================ + +import { useState, useEffect, useRef } from 'react'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { LOD_LEVELS } from '../utils/constants'; +import type { LODLevel } from '../utils/constants'; + +interface LODImageProps { + itemId: number; + filePath: string; + /** 当前需要的 LOD 级别(基于 zoom 计算) */ + targetLevel: number; // 0-3 index into LOD_LEVELS +} + +/** + * 根据 zoom 倍率确定 LOD 级别索引。 + * zoom < 0.5 → 512px, < 1.0 → 1024px, < 2.0 → 2048px, >= 2.0 → 4096px + */ +export function zoomToLODIndex(zoom: number): number { + if (zoom < 0.5) return 0; + if (zoom < 1.0) return 1; + if (zoom < 2.0) return 2; + return 3; +} + +export function LODImage({ itemId, filePath, targetLevel }: LODImageProps) { + const blobUrl = useCanvasStore((s) => { + const item = s.items.find((i) => i.id === itemId); + return item?.blobUrl; + }); + const loadedLevel = useCanvasStore((s) => { + const item = s.items.find((i) => i.id === itemId); + return item?.loadedLevel; + }); + const loadThumbnail = useCanvasStore((s) => s.loadThumbnail); + const getDataSource = useCanvasStore((s) => s.getDataSource); + + const [src, setSrc] = useState(blobUrl || null); + const [loading, setLoading] = useState(!blobUrl); + const [error, setError] = useState(false); + const loadingRef = useRef(false); + + // 当 Store 中的 blobUrl 更新时同步 + useEffect(() => { + if (blobUrl) { + setSrc(blobUrl); + setLoading(false); + } + }, [blobUrl]); + + // 按需加载更高清晰度的缩略图 + useEffect(() => { + // 已有足够清晰的版本 + if (loadedLevel !== undefined && loadedLevel >= targetLevel) return; + // 正在加载中 + if (loadingRef.current) return; + + const requiredSize = LOD_LEVELS[Math.min(targetLevel, LOD_LEVELS.length - 1)] as LODLevel; + + loadingRef.current = true; + + const dataSource = getDataSource(); + dataSource + .generateThumbnail({ filePath, size: requiredSize }) + .then((thumb) => { + const url = `data:${thumb.mimeType};base64,${thumb.data}`; + setSrc(url); + setLoading(false); + setError(false); + loadThumbnail(itemId, url, targetLevel); + loadingRef.current = false; + }) + .catch(() => { + setLoading(false); + setError(true); + loadingRef.current = false; + }); + }, [itemId, filePath, targetLevel, loadedLevel, loadThumbnail, getDataSource]); + + if (error) { + return ( +
+ 🖼️ +
+ ); + } + + return ( +
+ {src && ( + setLoading(false)} + /> + )} +
+ ); +} diff --git a/src/pages/infinite-canvas/core/MediaCard.tsx b/src/pages/infinite-canvas/core/MediaCard.tsx new file mode 100644 index 0000000..4d4dad1 --- /dev/null +++ b/src/pages/infinite-canvas/core/MediaCard.tsx @@ -0,0 +1,187 @@ +// ============================================================ +// MediaCard — 缩略图卡片(画布核心渲染单元) +// +// 功能: +// - LOD 多级缩略图加载(LODImage 组件) +// - 棋盘格背景(图片加载前由 LODImage 内部提供) +// - 审核角标(pending/approved/rework/deprecated) +// - 文件名标签 +// - 选中高亮(SELECT 模式) +// - 视频/音频的首帧/图标 +// ============================================================ + +import { useCallback, memo } from 'react'; +import { LODImage, zoomToLODIndex } from './LODImage'; +import { useCanvasStore, type InteractionMode } from '../store/useCanvasStore'; +import { AUDIT_STATUS_COLORS, ITEM_W, ITEM_H } from '../utils/constants'; + +export interface MediaCardProps { + itemId: number; + /** 在画布中的绝对像素坐标 */ + x: number; + y: number; + /** 当前视图缩放倍率 */ + zoom: number; + /** 当前交互模式 */ + mode: InteractionMode; + /** 双击回调 → 打开全屏预览 */ + onDoubleClick?: (itemId: number) => void; +} + +/** + * MediaCard 使用 React.memo 优化:仅在以下属性变化时重渲染: + * - itemId / x / y(位置变化) + * - zoom(影响 LOD 级别) + * - mode(影响选中行为和 UI) + * - blobUrl / auditStatus(从 Store 选择器获取) + */ +export const MediaCard = memo(function MediaCard({ itemId, x, y, zoom, mode, onDoubleClick }: MediaCardProps) { + const item = useCanvasStore((s) => s.items.find((i) => i.id === itemId)); + const selectedIds = useCanvasStore((s) => s.selectedIds); + const toggleSelection = useCanvasStore((s) => s.toggleSelection); + + const isVideo = ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(item?.ext || ''); + const isAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(item?.ext || ''); + const isSelected = selectedIds.has(itemId); + + const handleClick = useCallback(() => { + if (mode === 'SELECT') { + toggleSelection(itemId); + } + }, [mode, itemId, toggleSelection]); + + const handleDoubleClick = useCallback(() => { + onDoubleClick?.(itemId); + }, [itemId, onDoubleClick]); + + // 启动原生文件拖拽(拖放缩略图到 Photoshop/Explorer 等外部应用) + const handleDragStart = useCallback((e: React.DragEvent) => { + if (mode !== 'BROWSE' || !item) return; + if (typeof window.canvas?.startDrag === 'function') { + e.preventDefault(); + e.stopPropagation(); + window.canvas.startDrag({ + filePath: item.path, + fileName: item.fileName, + }).catch(() => { + // 静默忽略拖拽失败(文件可能已被移动或删除) + }); + } + }, [mode, item]); + + if (!item) return null; + + const lodIndex = zoomToLODIndex(zoom); + const auditStatus = item.metadata?.audit_status; + const auditColor = auditStatus ? AUDIT_STATUS_COLORS[auditStatus] : undefined; + + return ( +
+ {/* 缩略图区域 */} +
+ {isVideo || isAudio ? ( + /* 视频/音频:显示图标 */ +
+ {isVideo ? '🎥' : '🎵'} +
+ ) : ( + + )} + + {/* 审核角标 */} + {auditStatus && auditColor && ( +
+ )} + + {/* 选中勾选(SELECT 模式) */} + {mode === 'SELECT' && ( +
+ {isSelected ? '✓' : ''} +
+ )} +
+ + {/* 文件名标签 */} +
+ {item.fileName} +
+
+ ); +}); diff --git a/src/pages/infinite-canvas/core/MediaCardContextMenu.tsx b/src/pages/infinite-canvas/core/MediaCardContextMenu.tsx new file mode 100644 index 0000000..cb559c1 --- /dev/null +++ b/src/pages/infinite-canvas/core/MediaCardContextMenu.tsx @@ -0,0 +1,163 @@ +// ============================================================ +// MediaCardContextMenu — 右键菜单 +// +// 功能: +// - 打开文件所在位置(showItemInFolder) +// - 复制文件路径 +// - 切换审核状态 +// - 删除文件及其 sidecar +// ============================================================ + +import { Dropdown, type MenuProps, message, Modal } from 'antd'; +import { + FolderOpenOutlined, + CopyOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import { useCallback } from 'react'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { useSidecarSync } from '../hooks/useSidecarSync'; +import { safeIpcInvoke } from '@/utils/bus'; +import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; +import { ITEM_W, ITEM_H } from '../utils/constants'; +import type { AuditStatus } from '../store/useCanvasStore'; + +interface MediaCardContextMenuProps { + itemId: number; + children: React.ReactNode; + /** 画布中的绝对定位(传递给 Dropdown 外层容器,确保右键区域尺寸正确) */ + x: number; + y: number; +} + +export function MediaCardContextMenu({ itemId, children, x, y }: MediaCardContextMenuProps) { + const item = useCanvasStore((s) => s.items.find((i) => i.id === itemId)); + const getDataSource = useCanvasStore((s) => s.getDataSource); + const { commitMetadata } = useSidecarSync(); + + const handleShowInFolder = useCallback(async () => { + if (!item) return; + await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { + filePath: item.path, + }); + }, [item]); + + const handleCopyPath = useCallback(async () => { + if (!item) return; + try { + await navigator.clipboard.writeText(item.path); + void message.success('已复制文件路径'); + } catch { + void message.error('复制失败'); + } + }, [item]); + + const handleSetAudit = useCallback( + (status: AuditStatus) => { + if (!item) return; + // commitMetadata 同时更新 Store 内存 + 写入磁盘 .json 侧加载文件 + commitMetadata(itemId, item.path, { audit_status: status }); + void message.success(`审核状态已更新为:${status}`); + }, + [item, itemId, commitMetadata], + ); + + const handleDelete = useCallback(() => { + if (!item) return; + Modal.confirm({ + title: '确认删除', + content: `确定要删除「${item.fileName}」及其元数据吗?`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + const dataSource = getDataSource(); + await dataSource.deleteFile(item.path); + void message.success('已删除'); + } catch { + void message.error('删除失败'); + } + }, + }); + }, [item, getDataSource]); + + if (!item) return <>{children}; + + const isAuditable = ['Storyboard', 'Keyframe', 'Video'].includes(item.stage); + + const menuItems: MenuProps['items'] = [ + { + key: 'show', + label: '在文件管理器中显示', + icon: , + onClick: () => void handleShowInFolder(), + }, + { + key: 'copy', + label: '复制文件路径', + icon: , + onClick: () => void handleCopyPath(), + }, + ...(isAuditable + ? [ + { type: 'divider' as const }, + { + key: 'audit-header', + label: '审核状态', + disabled: true, + }, + { + key: 'audit-pending', + label: '⏳ 待审核', + icon: , + onClick: () => handleSetAudit('pending'), + }, + { + key: 'audit-approved', + label: '✅ 已通过', + icon: , + onClick: () => handleSetAudit('approved'), + }, + { + key: 'audit-rework', + label: '🔄 需返工', + icon: , + onClick: () => handleSetAudit('rework'), + }, + { + key: 'audit-deprecated', + label: '🗑️ 已废弃', + icon: , + onClick: () => handleSetAudit('deprecated'), + }, + ] + : []), + { type: 'divider' as const }, + { + key: 'delete', + label: '删除文件', + icon: , + danger: true, + onClick: () => handleDelete(), + }, + ]; + + return ( +
+ + {children} + +
+ ); +} diff --git a/src/pages/infinite-canvas/core/MediaPreview.tsx b/src/pages/infinite-canvas/core/MediaPreview.tsx new file mode 100644 index 0000000..a1b047a --- /dev/null +++ b/src/pages/infinite-canvas/core/MediaPreview.tsx @@ -0,0 +1,345 @@ +// ============================================================ +// MediaPreview — 全屏/灯箱预览 +// +// 双击 MediaCard 时弹出,显示大图/视频/音频。 +// - 图片:全屏显示原图(4096px LOD) +// - 视频:HTML5
+ ); +} diff --git a/src/pages/infinite-canvas/core/ViewportControls.tsx b/src/pages/infinite-canvas/core/ViewportControls.tsx new file mode 100644 index 0000000..bde3ffe --- /dev/null +++ b/src/pages/infinite-canvas/core/ViewportControls.tsx @@ -0,0 +1,217 @@ +// ============================================================ +// ViewportControls — 画布浮层控件 +// +// 合并 PagerOverlay + ZoomOverlay + ModeSwitch 三个控件。 +// 统一放置于画布视口底部,通过 position: absolute 定位。 +// ============================================================ + +import { Button } from 'antd'; +import { + LeftOutlined, + RightOutlined, + ZoomInOutlined, + ZoomOutOutlined, + ExpandOutlined, + EyeOutlined, + CheckSquareOutlined, + MenuOutlined, +} from '@ant-design/icons'; +import { useTheme } from '@/components/providers/ThemeProvider'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { useCanvasInteraction } from '../hooks/useCanvasInteraction'; +import { gridPageInfo, singlePageInfo } from '../utils/layout'; +import { MAX_ITEMS_PER_PAGE } from '../utils/constants'; + +// ── 分页控件 ── + +export function PagerOverlay() { + const { isDark } = useTheme(); + const items = useCanvasStore((s) => s.items); + const currentPage = useCanvasStore((s) => s.currentPage); + const layoutMode = useCanvasStore((s) => s.layoutMode); + const cols = useCanvasStore((s) => s.cols); + const rowsPerPage = useCanvasStore((s) => s.rowsPerPage); + const singleRowsPerPage = useCanvasStore((s) => s.singleRowsPerPage); + const gotoPage = useCanvasStore((s) => s.gotoPage); + + const totalPages = + layoutMode === 'grid' + ? gridPageInfo(items.length, cols, rowsPerPage, currentPage).totalPages + : singlePageInfo(items, singleRowsPerPage, currentPage).totalPages; + + const perPageText = + layoutMode === 'grid' + ? `${Math.min(cols * rowsPerPage, MAX_ITEMS_PER_PAGE)}` + : `${singleRowsPerPage} 组`; + + if (totalPages <= 1) return null; + + return ( +
+
+ ); +} + +// ── 缩放控件 ── + +export function ZoomOverlay() { + const { isDark } = useTheme(); + const zoom = useCanvasStore((s) => s.zoom); + const setZoom = useCanvasStore((s) => s.setZoom); + + const zoomPercent = Math.round(zoom * 100); + + return ( +
+
+ ); +} + +// ── 模式切换 ── + +export function ModeSwitch() { + const { isDark } = useTheme(); + const { interactionMode, switchMode } = useCanvasInteraction(); + + const modes = [ + { key: 'BROWSE' as const, icon: , label: '浏览', shortcut: 'V' }, + { key: 'SELECT' as const, icon: , label: '选择', shortcut: 'S' }, + { key: 'REORDER' as const, icon: , label: '排序', shortcut: 'R' }, + ]; + + return ( +
+ {modes.map(({ key, icon, label, shortcut }) => ( + + ))} +
+ ); +} diff --git a/src/pages/infinite-canvas/core/index.ts b/src/pages/infinite-canvas/core/index.ts new file mode 100644 index 0000000..34bedb4 --- /dev/null +++ b/src/pages/infinite-canvas/core/index.ts @@ -0,0 +1,11 @@ +// ============================================================ +// core — 核心画布:视口 + 媒体卡片 + 浮层控件 +// ============================================================ + +export { CanvasViewport } from './CanvasViewport'; +export { MediaCard } from './MediaCard'; +export type { MediaCardProps } from './MediaCard'; +export { LODImage, zoomToLODIndex } from './LODImage'; +export { MediaCardContextMenu } from './MediaCardContextMenu'; +export { MediaPreview } from './MediaPreview'; +export { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls'; diff --git a/src/pages/infinite-canvas/hooks/index.ts b/src/pages/infinite-canvas/hooks/index.ts new file mode 100644 index 0000000..f88e2ab --- /dev/null +++ b/src/pages/infinite-canvas/hooks/index.ts @@ -0,0 +1,7 @@ +// ============================================================ +// hooks — 无限画布自定义 Hook +// ============================================================ + +export { useDirectoryScan } from './useDirectoryScan'; +export { useCanvasInteraction } from './useCanvasInteraction'; +export { useSidecarSync } from './useSidecarSync'; diff --git a/src/pages/infinite-canvas/hooks/useCanvasInteraction.ts b/src/pages/infinite-canvas/hooks/useCanvasInteraction.ts new file mode 100644 index 0000000..f77e2e9 --- /dev/null +++ b/src/pages/infinite-canvas/hooks/useCanvasInteraction.ts @@ -0,0 +1,72 @@ +// ============================================================ +// useCanvasInteraction — 画布交互 Hook +// +// 提供: +// - 键盘快捷键处理(模式切换、全选、删除等) +// - 模式切换回调 +// - 当前交互模式的状态 +// ============================================================ + +import { useEffect, useCallback } from 'react'; +import { useCanvasStore, type InteractionMode } from '../store/useCanvasStore'; + +export function useCanvasInteraction() { + const interactionMode = useCanvasStore((s) => s.interactionMode); + const setInteractionMode = useCanvasStore((s) => s.setInteractionMode); + const clearSelection = useCanvasStore((s) => s.clearSelection); + + const switchMode = useCallback( + (mode: InteractionMode) => { + setInteractionMode(mode); + if (mode !== 'SELECT') { + clearSelection(); + } + }, + [setInteractionMode, clearSelection], + ); + + // 键盘快捷键 + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // 避免在输入框中处理快捷键 + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + + switch (e.key) { + case 'v': + case 'V': + // V → BROWSE mode + if (!e.ctrlKey && !e.metaKey) { + switchMode('BROWSE'); + } + break; + case 's': + case 'S': + // S → SELECT mode + if (!e.ctrlKey && !e.metaKey) { + switchMode('SELECT'); + } + break; + case 'r': + case 'R': + // R → REORDER mode + if (!e.ctrlKey && !e.metaKey) { + switchMode('REORDER'); + } + break; + case 'Escape': + // Esc → 取消选择 / 回到 BROWSE + clearSelection(); + if (interactionMode !== 'BROWSE') { + switchMode('BROWSE'); + } + break; + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [switchMode, clearSelection, interactionMode]); + + return { interactionMode, switchMode }; +} diff --git a/src/pages/infinite-canvas/hooks/useDirectoryScan.ts b/src/pages/infinite-canvas/hooks/useDirectoryScan.ts new file mode 100644 index 0000000..36bc930 --- /dev/null +++ b/src/pages/infinite-canvas/hooks/useDirectoryScan.ts @@ -0,0 +1,163 @@ +// ============================================================ +// useDirectoryScan — 目录扫描 Hook +// +// 根据 selectedProject / selectedEpisode / selectedShot / currentStage +// 智能选择扫描策略: +// - Assets 阶段 → scanAssetFiles(深度递归扫描 assets/) +// - 非 Assets 阶段 → scanStageFiles(扫描 shots/{ep}/{shot}/{stage}/) +// +// 防抖 300ms,窗口聚焦时自动重扫。 +// ============================================================ + +import { useEffect, useRef, useCallback } from 'react'; +import { useCanvasStore } from '../store/useCanvasStore'; + +export function useDirectoryScan() { + // 选择性订阅 + const workspaceRoot = useCanvasStore((s) => s.workspaceRoot); + const selectedProject = useCanvasStore((s) => s.selectedProject); + const selectedEpisode = useCanvasStore((s) => s.selectedEpisode); + const selectedShot = useCanvasStore((s) => s.selectedShot); + const currentStage = useCanvasStore((s) => s.currentStage); + const assetTypeFilter = useCanvasStore((s) => s.assetTypeFilter); + const keywordQuery = useCanvasStore((s) => s.keywordQuery); + const tagQuery = useCanvasStore((s) => s.tagQuery); + + const clearItems = useCanvasStore((s) => s.clearItems); + const appendItems = useCanvasStore((s) => s.appendItems); + const setIsScanning = useCanvasStore((s) => s.setIsScanning); + const setScanProgress = useCanvasStore((s) => s.setScanProgress); + const getDataSource = useCanvasStore((s) => s.getDataSource); + const getProjectPath = useCanvasStore((s) => s.getProjectPath); + + // 扫描 abort 控制器 ref + const abortRef = useRef(null); + // 防抖 timer + const debounceRef = useRef | null>(null); + // 当前 epoch + const epochRef = useRef(0); + + const doScan = useCallback(async () => { + const projectPath = getProjectPath(); + if (!projectPath) { + console.log('[useDirectoryScan] ⏸️ 跳过扫描:projectPath 为空(未选择项目)'); + clearItems(); + return; + } + + // 取消之前的扫描 + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const epoch = ++epochRef.current; + + const isAssets = currentStage === 'Assets'; + + console.log(`[useDirectoryScan] 🔍 开始扫描 (epoch=${epoch})`, { + projectPath, + stage: currentStage, + isAssets, + selectedEpisode: selectedEpisode || '(未选)', + selectedShot: selectedShot || '(未选)', + }); + + if (!isAssets && (!selectedEpisode || !selectedShot)) { + console.log('[useDirectoryScan] ⏸️ 跳过扫描:非 Assets 阶段需先选择集数和镜头'); + clearItems(); + return; + } + + const t0 = performance.now(); + setIsScanning(true); + setScanProgress({ found: 0, currentPath: '' }); + + try { + const dataSource = getDataSource(); + let entries: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; + + if (isAssets) { + // Assets 阶段:深度递归扫描 + entries = await dataSource.scanAssetFiles({ + projectPath, + assetTypeFilter, + }); + } else { + // 非 Assets 阶段:扫描指定镜头的阶段目录 + entries = await dataSource.scanStageFiles({ + projectPath, + episode: selectedEpisode!, + shot: selectedShot!, + stage: currentStage, + }); + } + + // Epoch 检查 + if (epoch !== epochRef.current) { + console.log(`[useDirectoryScan] ⚠️ epoch 过期 (${epoch} → ${epochRef.current}),丢弃结果`); + return; + } + if (controller.signal.aborted) { + console.log('[useDirectoryScan] ⚠️ 扫描已被 abort,丢弃结果'); + return; + } + + const elapsed = (performance.now() - t0).toFixed(0); + console.log(`[useDirectoryScan] ✅ 扫描完成 (epoch=${epoch}, 耗时=${elapsed}ms)`, { + totalEntries: entries.length, + }); + + clearItems(); + appendItems(entries); + setScanProgress(null); + } catch (err) { + if (controller.signal.aborted || epoch !== epochRef.current) return; + console.error('[useDirectoryScan] ❌ 扫描失败:', err); + } finally { + if (epoch === epochRef.current) { + setIsScanning(false); + } + } + }, [ + selectedProject, + selectedEpisode, + selectedShot, + currentStage, + assetTypeFilter, + clearItems, + appendItems, + setIsScanning, + setScanProgress, + getDataSource, + getProjectPath, + ]); + + // 防抖触发扫描 + useEffect(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + void doScan(); + }, 100); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [doScan, workspaceRoot, keywordQuery, tagQuery]); + + // 窗口聚焦时重新扫描 + useEffect(() => { + const handleFocus = () => { + setTimeout(() => { + void doScan(); + }, 500); + }; + window.addEventListener('focus', handleFocus); + return () => window.removeEventListener('focus', handleFocus); + }, [doScan]); + + return { rescan: doScan }; +} diff --git a/src/pages/infinite-canvas/hooks/useSidecarSync.ts b/src/pages/infinite-canvas/hooks/useSidecarSync.ts new file mode 100644 index 0000000..c74786e --- /dev/null +++ b/src/pages/infinite-canvas/hooks/useSidecarSync.ts @@ -0,0 +1,59 @@ +// ============================================================ +// useSidecarSync — Sidecar 元数据防抖同步 Hook +// +// QuickEditPanel 编辑 → 250ms 防抖 → dataSource.writeSidecar() → 保存到 JSON。 +// Phase 4 扩展:写入后通过事件总线通知宿主窗口刷新。 +// ============================================================ + +import { useRef, useCallback } from 'react'; +import { useCanvasStore, getCanvasStore } from '../store/useCanvasStore'; +import type { SidecarMetadata } from '../utils/ICanvasDataSource'; + +export function useSidecarSync() { + const updateItemMetadata = useCanvasStore((s) => s.updateItemMetadata); + const getDataSource = useCanvasStore((s) => s.getDataSource); + const timerRef = useRef | null>(null); + const pendingRef = useRef>(new Map()); + + /** + * 提交元数据更新(250ms 防抖)。 + * 更新 Store → 延迟写入 sidecar JSON(失败时保留 Store 中的副本)。 + */ + const commitMetadata = useCallback( + (itemId: number, filePath: string, partial: Partial) => { + const current = + pendingRef.current.get(itemId) || + ({} as SidecarMetadata); + const merged = { ...current, ...partial }; + pendingRef.current.set(itemId, merged); + + // 立即更新 Store(乐观更新) + updateItemMetadata(itemId, partial); + + // 防抖写入 sidecar + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(async () => { + const batch = new Map(pendingRef.current); + pendingRef.current.clear(); + + const dataSource = getDataSource(); + const store = getCanvasStore(); + for (const [id, meta] of batch) { + try { + const item = store.getState().items.find( + (i) => i.id === id, + ); + if (item) { + await dataSource.writeSidecar(item.path, meta); + } + } catch { + console.warn(`[useSidecarSync] 写入 sidecar 失败: ${filePath}`); + } + } + }, 250); + }, + [updateItemMetadata, getDataSource], + ); + + return { commitMetadata }; +} diff --git a/src/pages/infinite-canvas/navigation/ContextMenu.tsx b/src/pages/infinite-canvas/navigation/ContextMenu.tsx new file mode 100644 index 0000000..1fadef5 --- /dev/null +++ b/src/pages/infinite-canvas/navigation/ContextMenu.tsx @@ -0,0 +1,39 @@ +// ============================================================ +// ContextMenu — 可复用右键菜单封装 +// +// 用法: +// ... }]}> +// +// +// ============================================================ + +import { type ReactNode } from 'react'; +import { Dropdown, type MenuProps } from 'antd'; + +export interface ContextMenuItem { + label: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; +} + +interface ContextMenuProps { + items: ContextMenuItem[]; + children: ReactNode; +} + +export function ContextMenu({ items, children }: ContextMenuProps) { + const menuItems: MenuProps['items'] = items.map((item, i) => ({ + key: String(i), + label: item.label, + onClick: item.onClick, + disabled: item.disabled, + danger: item.danger, + })); + + return ( + + {children} + + ); +} diff --git a/src/pages/infinite-canvas/navigation/DirectoryCreateModal.tsx b/src/pages/infinite-canvas/navigation/DirectoryCreateModal.tsx new file mode 100644 index 0000000..2bcd59b --- /dev/null +++ b/src/pages/infinite-canvas/navigation/DirectoryCreateModal.tsx @@ -0,0 +1,74 @@ +// ============================================================ +// DirectoryCreateModal — 可复用新建目录弹窗 +// +// 用法: +// handleCreate(name)} +// onCancel={() => setOpen(false)} +// /> +// ============================================================ + +import { useState, useCallback } from 'react'; +import { Modal, Input } from 'antd'; + +interface DirectoryCreateModalProps { + open: boolean; + title: string; + onOk: (name: string) => void; + onCancel: () => void; +} + +/** 危险字符正则(Windows + 通用非法字符) */ +const DANGEROUS_CHARS = /[/\\:*?"<>|]/; + +export function DirectoryCreateModal({ open, title, onOk, onCancel }: DirectoryCreateModalProps) { + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + + const handleOk = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed) { + setError('名称不能为空'); + return; + } + if (DANGEROUS_CHARS.test(trimmed)) { + setError('名称包含非法字符(/ \\ : * ? " < > |)'); + return; + } + setError(null); + setValue(''); + onOk(trimmed); + }, [value, onOk]); + + const handleCancel = useCallback(() => { + setError(null); + setValue(''); + onCancel(); + }, [onCancel]); + + return ( + + { + setValue(e.target.value); + if (error) setError(null); + }} + onPressEnter={handleOk} + status={error ? 'error' : undefined} + /> + {error &&
{error}
} +
+ ); +} diff --git a/src/pages/infinite-canvas/navigation/EpisodeSelector.tsx b/src/pages/infinite-canvas/navigation/EpisodeSelector.tsx new file mode 100644 index 0000000..d2773dd --- /dev/null +++ b/src/pages/infinite-canvas/navigation/EpisodeSelector.tsx @@ -0,0 +1,103 @@ +// ============================================================ +// EpisodeSelector — 集数下拉选择器 +// +// 列出 selectedProject/shots/ 下的子文件夹作为可选集数。 +// 右击可新建集数。Assets 阶段隐藏。 +// ============================================================ + +import { useState, useCallback } from 'react'; +import { Select, App } from 'antd'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { ContextMenu } from './ContextMenu'; +import { DirectoryCreateModal } from './DirectoryCreateModal'; + +export function EpisodeSelector() { + const { message } = App.useApp(); + const workspaceRoot = useCanvasStore((s) => s.workspaceRoot); + const selectedProject = useCanvasStore((s) => s.selectedProject); + const episodes = useCanvasStore((s) => s.episodes); + const selectedEpisode = useCanvasStore((s) => s.selectedEpisode); + const selectEpisode = useCanvasStore((s) => s.selectEpisode); + const setShots = useCanvasStore((s) => s.setShots); + const setEpisodes = useCanvasStore((s) => s.setEpisodes); + const currentStage = useCanvasStore((s) => s.currentStage); + const getDataSource = useCanvasStore((s) => s.getDataSource); + + const [createOpen, setCreateOpen] = useState(false); + + const handleSelect = useCallback( + async (name: string) => { + selectEpisode(name); + // 异步加载该集数下的镜头列表 + try { + if (!workspaceRoot || !selectedProject) return; + const dataSource = getDataSource(); + const shotsPath = `${workspaceRoot}\\${selectedProject}\\shots\\${name}`; + const shots = await dataSource.listSubdirs(shotsPath); + setShots(shots); + } catch (err) { + console.error('[EpisodeSelector] 加载镜头列表失败:', err); + } + }, + [workspaceRoot, selectedProject, selectEpisode, setShots, getDataSource], + ); + + const handleCreate = useCallback( + async (dirName: string) => { + if (!workspaceRoot || !selectedProject) return; + try { + const dataSource = getDataSource(); + const parentPath = `${workspaceRoot}\\${selectedProject}\\shots`; + await dataSource.createDirectory(parentPath, dirName); + // 刷新集数列表 + const updated = await dataSource.listSubdirs(parentPath); + setEpisodes(updated); + // 自动选中新集数 + await handleSelect(dirName); + message.success(`集数 "${dirName}" 已创建`); + } catch (err) { + message.error(`创建失败: ${(err as Error).message}`); + } + }, + [workspaceRoot, selectedProject, getDataSource, setEpisodes, handleSelect, message], + ); + + // Assets 阶段隐藏集数选择器 + if (currentStage === 'Assets') return null; + + const options = episodes.map((ep) => ({ value: ep, label: ep })); + + return ( + <> + setCreateOpen(true), + disabled: !selectedProject, + }, + ]} + > + + (option?.label as string)?.toLowerCase().includes(input.toLowerCase()) + } + /> + + setCreateOpen(false)} + /> + + ); +} diff --git a/src/pages/infinite-canvas/navigation/ShotList.tsx b/src/pages/infinite-canvas/navigation/ShotList.tsx new file mode 100644 index 0000000..21c26bf --- /dev/null +++ b/src/pages/infinite-canvas/navigation/ShotList.tsx @@ -0,0 +1,126 @@ +// ============================================================ +// ShotList — 左侧镜头单选列表 +// +// 列出 selectedProject/shots/selectedEpisode/ 下的子文件夹。 +// Assets 阶段隐藏。 +// ============================================================ + +import { useCallback } from 'react'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { useTheme } from '@/components/providers/ThemeProvider'; + +const SHOT_LIST_WIDTH = 200; + +export function ShotList() { + const { isDark } = useTheme(); + const shots = useCanvasStore((s) => s.shots); + const selectedShot = useCanvasStore((s) => s.selectedShot); + const selectedEpisode = useCanvasStore((s) => s.selectedEpisode); + const selectShot = useCanvasStore((s) => s.selectShot); + const currentStage = useCanvasStore((s) => s.currentStage); + + const handleClick = useCallback( + (shotName: string) => { + selectShot(shotName); + }, + [selectShot], + ); + + // Assets 阶段隐藏镜头列表 + if (currentStage === 'Assets') return null; + + const bg = isDark ? '#0F0D21' : '#F5F3FF'; + const borderColor = isDark ? '#2D2A4A' : '#E5E0F0'; + const textColor = isDark ? '#C8C4E0' : '#4A4570'; + const activeBg = isDark ? '#2D2A5A' : '#E8E4FF'; + const activeColor = isDark ? '#C4B5FD' : '#6D28D9'; + const hoverBg = isDark ? '#1E1B3B' : '#EDEAFA'; + + return ( +
+ {/* 标题 */} +
+ 镜头列表 + {shots.length > 0 && ( + ({shots.length}) + )} +
+ + {/* 列表 */} +
+ {!selectedEpisode ? ( +
+ 请先选择集数 +
+ ) : shots.length === 0 ? ( +
+ 该集数下无镜头 +
+ ) : ( + shots.map((shot) => { + const active = shot === selectedShot; + return ( +
handleClick(shot)} + style={{ + padding: '6px 12px', + cursor: 'pointer', + fontSize: 13, + color: active ? activeColor : textColor, + background: active ? activeBg : 'transparent', + transition: 'background 0.15s', + userSelect: 'none', + }} + onMouseEnter={(e) => { + if (!active) + (e.currentTarget as HTMLElement).style.background = hoverBg; + }} + onMouseLeave={(e) => { + if (!active) + (e.currentTarget as HTMLElement).style.background = + 'transparent'; + }} + > + {shot} +
+ ); + }) + )} +
+
+ ); +} diff --git a/src/pages/infinite-canvas/navigation/index.ts b/src/pages/infinite-canvas/navigation/index.ts new file mode 100644 index 0000000..68e693e --- /dev/null +++ b/src/pages/infinite-canvas/navigation/index.ts @@ -0,0 +1,10 @@ +// ============================================================ +// navigation — 项目/集数/镜头三级导航选择器 +// ============================================================ + +export { ProjectSelector } from './ProjectSelector'; +export { EpisodeSelector } from './EpisodeSelector'; +export { ShotList } from './ShotList'; +export { ContextMenu } from './ContextMenu'; +export type { ContextMenuItem } from './ContextMenu'; +export { DirectoryCreateModal } from './DirectoryCreateModal'; diff --git a/src/pages/infinite-canvas/store/useCanvasStore.ts b/src/pages/infinite-canvas/store/useCanvasStore.ts new file mode 100644 index 0000000..4e2bea3 --- /dev/null +++ b/src/pages/infinite-canvas/store/useCanvasStore.ts @@ -0,0 +1,384 @@ +// ============================================================ +// useCanvasStore — 无限画布状态管理 (Zustand) +// +// 对应原版 CatalogModel + SelectionModel + CanvasStateMachine +// ============================================================ + +import { createContext, useContext, createElement, type ReactNode } from 'react'; +import { createStore, useStore } from 'zustand'; +import type { ICanvasDataSource, SidecarMetadata } from '../utils/ICanvasDataSource'; +import { + extractEpisodeName, + extractShotName, + groupKeyForPath, +} from '../utils/scanner'; + +// ---------- 公开类型 ---------- + +/** 阶段类型(按 UI Tab 顺序排列) */ +export type StageType = 'Assets' | 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync'; +export type AuditStatus = 'pending' | 'approved' | 'rework' | 'deprecated'; +export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景'; +export type LayoutMode = 'grid' | 'single'; +export type InteractionMode = 'BROWSE' | 'SELECT' | 'REORDER'; + +// ---------- CatalogItem ---------- + +/** 画布中单个文件条目的完整状态 */ +export interface CatalogItem { + id: number; + path: string; + fileName: string; + ext: string; + stage: StageType; + episodeName: string; + shotName: string; + groupKey: string; + metadata: SidecarMetadata | null; + fileSize: number; + modifiedAt: string; + /** 已加载的缩略图 Blob URL */ + blobUrl?: string; + /** 已加载的 LOD 级别 */ + loadedLevel?: number; +} + +// ---------- Store State ---------- + +export interface CanvasState { + // ── 工作区 ── + workspaceRoot: string | null; + + // ── 目录导航 ── + /** workspaceRoot 下的项目文件夹名列表 */ + projects: string[]; + /** 当前选中的项目名(非完整路径) */ + selectedProject: string | null; + /** selectedProject/shots/ 下的集数名列表 */ + episodes: string[]; + /** 当前选中的集数名 */ + selectedEpisode: string | null; + /** selectedProject/shots/episode/ 下的镜头名列表 */ + shots: string[]; + /** 当前选中的镜头名 */ + selectedShot: string | null; + + // ── Catalog ── + items: CatalogItem[]; + totalCount: number; + + // ── Selection ── + selectedIds: Set; + + // ── Stage & Filters ── + currentStage: StageType; + keywordQuery: string; + tagQuery: string; + auditFilter: AuditStatus | 'all'; + assetTypeFilter: AssetTypeFilter; + + // ── Layout ── + layoutMode: LayoutMode; + cols: number; + rowsPerPage: number; + singleRowsPerPage: number; + + // ── Pagination ── + currentPage: number; + pageEpoch: number; + + // ── Canvas transform ── + zoom: number; + panOffset: { x: number; y: number }; + + // ── Interaction mode ── + interactionMode: InteractionMode; + + // ── Meta ── + isScanning: boolean; + scanProgress: { found: number; currentPath: string } | null; + + // ── Actions ── + setWorkspace: (root: string | null) => void; + /** 计算当前完整项目路径 */ + getProjectPath: () => string | null; + setProjects: (projects: string[]) => void; + selectProject: (name: string | null) => void; + setEpisodes: (episodes: string[]) => void; + selectEpisode: (name: string | null) => void; + setShots: (shots: string[]) => void; + selectShot: (name: string | null) => void; + + setStage: (stage: StageType) => void; + setFilters: ( + filters: Partial< + Pick< + CanvasState, + 'keywordQuery' | 'tagQuery' | 'auditFilter' | 'assetTypeFilter' + > + >, + ) => void; + setLayoutMode: (mode: LayoutMode) => void; + setScanProgress: (progress: CanvasState['scanProgress']) => void; + setIsScanning: (isScanning: boolean) => void; + appendItems: (entries: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>) => void; + clearItems: () => void; + gotoPage: (page: number) => void; + setZoom: (zoom: number) => void; + setPanOffset: (offset: { x: number; y: number }) => void; + setInteractionMode: (mode: InteractionMode) => void; + toggleSelection: (id: number) => void; + selectAll: () => void; + clearSelection: () => void; + updateItemMetadata: (id: number, metadata: Partial) => void; + loadThumbnail: (id: number, blobUrl: string, level: number) => void; + /** 获取 dataSource(供 hooks 使用) */ + getDataSource: () => ICanvasDataSource; +} + +// ---------- Store 逻辑工厂 ---------- + +function createCanvasStoreLogic(dataSource: ICanvasDataSource, initialWorkspace: string | null) { + return createStore()((set, get) => ({ + workspaceRoot: initialWorkspace, + + projects: [], + selectedProject: null, + episodes: [], + selectedEpisode: null, + shots: [], + selectedShot: null, + + items: [], + totalCount: 0, + + selectedIds: new Set(), + + currentStage: 'Storyboard', + keywordQuery: '', + tagQuery: '', + auditFilter: 'all', + assetTypeFilter: '全部', + + layoutMode: 'grid', + cols: 9, + rowsPerPage: 16, + singleRowsPerPage: 16, + + currentPage: 1, + pageEpoch: 0, + + zoom: 1.0, + panOffset: { x: 0, y: 0 }, + + interactionMode: 'BROWSE', + + isScanning: false, + scanProgress: null, + + // ── Actions ── + + setWorkspace: (root) => + set({ + workspaceRoot: root, + projects: [], + selectedProject: null, + episodes: [], + selectedEpisode: null, + shots: [], + selectedShot: null, + currentPage: 1, + items: [], + totalCount: 0, + }), + + getProjectPath: () => { + const { workspaceRoot, selectedProject } = get(); + if (!workspaceRoot || !selectedProject) return null; + return `${workspaceRoot}\\${selectedProject}`; + }, + + setProjects: (projects) => set({ projects }), + + // 选中项目:立即清空 items 并标记扫描中,避免旧数据显示残留 + selectProject: (name) => + set({ + selectedProject: name, + episodes: [], + selectedEpisode: null, + shots: [], + selectedShot: null, + items: [], + totalCount: 0, + isScanning: true, + }), + + setEpisodes: (episodes) => set({ episodes }), + + // 选中集数:立即清空 items 并标记扫描中 + selectEpisode: (name) => + set({ + selectedEpisode: name, + shots: [], + selectedShot: null, + items: [], + totalCount: 0, + isScanning: true, + }), + + setShots: (shots) => set({ shots }), + + // 选中镜头:立即清空 items 并标记扫描中 + selectShot: (name) => + set({ + selectedShot: name, + items: [], + totalCount: 0, + isScanning: true, + }), + + // 切换阶段:立即清空 items 并标记扫描中 + setStage: (stage) => + set({ currentStage: stage, currentPage: 1, items: [], totalCount: 0, isScanning: true }), + + setFilters: (filters) => set(filters as Partial), + + setLayoutMode: (mode) => set({ layoutMode: mode, currentPage: 1 }), + + setScanProgress: (progress) => set({ scanProgress: progress }), + + setIsScanning: (scanning) => set({ isScanning: scanning }), + + appendItems: (entries) => { + const state = get(); + const nextId = state.items.length; + const stage = state.currentStage; + const newItems: CatalogItem[] = entries.map((entry, i) => ({ + id: nextId + i + 1, + path: entry.path, + fileName: entry.fileName, + ext: entry.ext, + stage, + episodeName: extractEpisodeName(entry.path), + shotName: extractShotName(entry.path), + groupKey: groupKeyForPath(entry.path, stage), + metadata: null, + fileSize: entry.fileSize, + modifiedAt: entry.modifiedAt, + })); + set({ + items: [...state.items, ...newItems], + totalCount: state.items.length + newItems.length, + }); + }, + + clearItems: () => set({ items: [], totalCount: 0, currentPage: 1 }), + + gotoPage: (page) => + set({ currentPage: page, pageEpoch: get().pageEpoch + 1 }), + + setZoom: (zoom) => set({ zoom: Math.max(0.02, Math.min(16.0, zoom)) }), + + setPanOffset: (panOffset) => set({ panOffset }), + + setInteractionMode: (mode) => set({ interactionMode: mode }), + + toggleSelection: (id) => { + const selected = new Set(get().selectedIds); + if (selected.has(id)) selected.delete(id); + else selected.add(id); + set({ selectedIds: selected }); + }, + + selectAll: () => { + const ids = new Set(get().items.map((i) => i.id)); + set({ selectedIds: ids }); + }, + + clearSelection: () => set({ selectedIds: new Set() }), + + updateItemMetadata: (id, partialMeta) => { + set({ + items: get().items.map((item) => + item.id === id + ? { + ...item, + metadata: { + ...(item.metadata || { + schema_version: 1, + favorite: false, + remark: '', + description: '', + tags: [], + create_time: new Date().toISOString(), + original_name: item.fileName, + }), + ...partialMeta, + } as SidecarMetadata, + } + : item, + ), + }); + }, + + loadThumbnail: (id, blobUrl, level) => { + set({ + items: get().items.map((item) => + item.id === id ? { ...item, blobUrl, loadedLevel: level } : item, + ), + }); + }, + + getDataSource: () => dataSource, + })); +} + +// ---------- Context & Provider ---------- + +type CanvasStore = ReturnType; + +/** 模块级 Store 引用(供非组件代码如 useSidecarSync 同步读取状态) */ +let _canvasStore: CanvasStore | null = null; + +/** 获取原始 Store 实例(用于 .getState() 同步读取) */ +export function getCanvasStore(): CanvasStore { + if (!_canvasStore) { + throw new Error('CanvasStore 尚未初始化,请在 CanvasStoreProvider 内部使用'); + } + return _canvasStore; +} + +const CanvasStoreContext = createContext(null); + +/** + * 创建 Canvas Store 和对应的 Provider 组件。 + * 接收 ICanvasDataSource 实例,注入到 Store 逻辑中。 + */ +export function createCanvasStoreProvider(dataSource: ICanvasDataSource, initialWorkspace?: string | null) { + const store = createCanvasStoreLogic(dataSource, initialWorkspace ?? null); + _canvasStore = store; + + function CanvasStoreProvider({ children }: { children: ReactNode }) { + return createElement(CanvasStoreContext.Provider, { value: store }, children); + } + + return { CanvasStoreProvider, store }; +} + +// ---------- Hook ---------- + +/** + * 从 Zustand Store 中选择状态。 + * 使用选择器模式,仅在选中状态变化时触发重渲染。 + * + * 用法: + * const items = useCanvasStore(s => s.items); + * const zoom = useCanvasStore(s => s.zoom); + */ +export function useCanvasStore(selector: (state: CanvasState) => T): T { + const store = useContext(CanvasStoreContext); + if (!store) { + throw new Error('useCanvasStore 必须在 CanvasStoreProvider 内部使用'); + } + return useStore(store, selector); +} diff --git a/src/pages/infinite-canvas/toolbar/CanvasFilterBar.tsx b/src/pages/infinite-canvas/toolbar/CanvasFilterBar.tsx new file mode 100644 index 0000000..dfb703d --- /dev/null +++ b/src/pages/infinite-canvas/toolbar/CanvasFilterBar.tsx @@ -0,0 +1,89 @@ +// ============================================================ +// CanvasFilterBar — 画布筛选栏 +// +// 提供:关键词搜索、标签筛选、审核状态筛选、镜头查询、资产类型筛选 +// 每个筛选条件变化后触发 useDirectoryScan(防抖 300ms) +// ============================================================ + +import { useCallback } from 'react'; +import { Input, Select } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import { useTheme } from '@/components/providers/ThemeProvider'; +import { useCanvasStore, type AuditStatus, type AssetTypeFilter } from '../store/useCanvasStore'; + +const AUDIT_OPTIONS: Array<{ value: AuditStatus | 'all'; label: string }> = [ + { value: 'all', label: '全部审核' }, + { value: 'pending', label: '待审核' }, + { value: 'approved', label: '已通过' }, + { value: 'rework', label: '需返工' }, + { value: 'deprecated', label: '已废弃' }, +]; + +const ASSET_TYPE_OPTIONS: Array<{ value: AssetTypeFilter; label: string }> = [ + { value: '全部', label: '全部资产' }, + { value: '角色', label: '角色' }, + { value: '道具', label: '道具' }, + { value: '场景', label: '场景' }, +]; + +export function CanvasFilterBar() { + const { isDark } = useTheme(); + const keywordQuery = useCanvasStore((s) => s.keywordQuery); + const auditFilter = useCanvasStore((s) => s.auditFilter); + const assetTypeFilter = useCanvasStore((s) => s.assetTypeFilter); + const currentStage = useCanvasStore((s) => s.currentStage); + const setFilters = useCanvasStore((s) => s.setFilters); + + const isAssets = currentStage === 'Assets'; + + const handleKeywordChange = useCallback( + (e: React.ChangeEvent) => { + setFilters({ keywordQuery: e.target.value }); + }, + [setFilters], + ); + + const inputStyle: React.CSSProperties = { + background: isDark ? '#1E1B4B' : '#FFFFFF', + borderColor: isDark ? '#3E3A6A' : '#D1D5DB', + color: isDark ? '#E8E6F0' : '#1E1B4B', + borderRadius: 4, + }; + + return ( +
+ {/* 关键词搜索 */} + } + value={keywordQuery} + onChange={handleKeywordChange} + allowClear + size="small" + style={{ width: 180, ...inputStyle }} + /> + + {/* 审核状态筛选(仅可审核阶段) */} + {!isAssets && ( + setFilters({ assetTypeFilter: v })} + size="small" + style={{ width: 110 }} + options={ASSET_TYPE_OPTIONS} + /> + )} +
+ ); +} diff --git a/src/pages/infinite-canvas/toolbar/CanvasToolbar.tsx b/src/pages/infinite-canvas/toolbar/CanvasToolbar.tsx new file mode 100644 index 0000000..a330c1e --- /dev/null +++ b/src/pages/infinite-canvas/toolbar/CanvasToolbar.tsx @@ -0,0 +1,129 @@ +// ============================================================ +// CanvasToolbar — 顶部工具栏包装器 +// +// 包含:工作区选择 + 项目选择 + 集数选择 + StageTabs + FilterBar +// ============================================================ + +import { Button, message } from 'antd'; +import { FolderOpenOutlined } from '@ant-design/icons'; +import { useTheme } from '@/components/providers/ThemeProvider'; +import { useCanvasStore } from '../store/useCanvasStore'; +import { StageTabs } from './StageTabs'; +import { CanvasFilterBar } from './CanvasFilterBar'; +import { ProjectSelector } from '../navigation/ProjectSelector'; +import { EpisodeSelector } from '../navigation/EpisodeSelector'; + +export function CanvasToolbar() { + const { isDark } = useTheme(); + const workspaceRoot = useCanvasStore((s) => s.workspaceRoot); + const setWorkspace = useCanvasStore((s) => s.setWorkspace); + const setProjects = useCanvasStore((s) => s.setProjects); + const getDataSource = useCanvasStore((s) => s.getDataSource); + const isScanning = useCanvasStore((s) => s.isScanning); + const totalCount = useCanvasStore((s) => s.totalCount); + + const handleOpenWorkspace = async () => { + try { + const dataSource = getDataSource(); + const dir = await dataSource.openDirectoryDialog(); + if (dir) { + setWorkspace(dir); + // 异步加载项目列表 + try { + const projects = await dataSource.listSubdirs(dir); + setProjects(projects); + } catch (err) { + console.error('[CanvasToolbar] 加载项目列表失败:', err); + } + } + } catch (err) { + void message.error((err as Error).message || '打开目录失败'); + } + }; + + const barBg = isDark + ? 'linear-gradient(180deg, #1E1B4B 0%, #15132E 100%)' + : 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)'; + + const borderColor = isDark ? '#2E2A5A' : '#E5E7EB'; + + return ( +
+ {/* 第一行:工作区 + 项目选择器 + 集数选择器 + 阶段标签 */} +
+ + + {workspaceRoot && } + {workspaceRoot && } + +
+ + + + {isScanning && ( + + 扫描中… + + )} + + {totalCount > 0 && ( + + {totalCount} 个文件 + + )} +
+ + {/* 第二行:筛选栏 */} + {workspaceRoot && ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/infinite-canvas/toolbar/StageTabs.tsx b/src/pages/infinite-canvas/toolbar/StageTabs.tsx new file mode 100644 index 0000000..795a1f5 --- /dev/null +++ b/src/pages/infinite-canvas/toolbar/StageTabs.tsx @@ -0,0 +1,57 @@ +// ============================================================ +// StageTabs — 阶段切换标签栏 +// +// 六个阶段:Storyboard / Keyframe / Video / Audio / LipSync / Assets +// 点击切换 store.currentStage,触发 useDirectoryScan 重新扫描 +// ============================================================ + +import { useTheme } from '@/components/providers/ThemeProvider'; +import { useCanvasStore, type StageType } from '../store/useCanvasStore'; +import { STAGE_CONFIGS } from '../utils/constants'; + +export function StageTabs() { + const { isDark } = useTheme(); + const currentStage = useCanvasStore((s) => s.currentStage); + const setStage = useCanvasStore((s) => s.setStage); + + return ( +
+ {STAGE_CONFIGS.map((cfg) => { + const isActive = currentStage === cfg.key; + return ( + + ); + })} +
+ ); +} diff --git a/src/pages/infinite-canvas/toolbar/index.ts b/src/pages/infinite-canvas/toolbar/index.ts new file mode 100644 index 0000000..001be25 --- /dev/null +++ b/src/pages/infinite-canvas/toolbar/index.ts @@ -0,0 +1,7 @@ +// ============================================================ +// toolbar — 顶部工具栏:布局 + 筛选 + 阶段切换 +// ============================================================ + +export { CanvasToolbar } from './CanvasToolbar'; +export { CanvasFilterBar } from './CanvasFilterBar'; +export { StageTabs } from './StageTabs'; diff --git a/src/pages/infinite-canvas/utils/ICanvasDataSource.ts b/src/pages/infinite-canvas/utils/ICanvasDataSource.ts new file mode 100644 index 0000000..5fd43e3 --- /dev/null +++ b/src/pages/infinite-canvas/utils/ICanvasDataSource.ts @@ -0,0 +1,183 @@ +// ============================================================ +// ICanvasDataSource — 数据源抽象接口 +// +// 定义离线(本地文件系统)和在线(远程 API)模式的统一契约。 +// 未来切换到在线协作时只需替换实现,上层代码无需修改。 +// +// 设计原则: +// 1. Store 和 UI 组件不直接依赖 IPC 或 HTTP +// 2. 所有文件操作通过此接口调用 +// 3. 测试时可用 Mock 实现替换 +// ============================================================ + +// ---------- 扫描相关 ---------- + +/** 目录扫描请求参数 */ +export interface ScanRequest { + /** 扫描根目录列表(绝对路径,离线模式)或项目 ID 列表(在线模式) */ + roots: string[]; + /** 允许的文件扩展名(小写,不含点) */ + extensions: string[]; + /** 是否递归扫描子目录 */ + recursive: boolean; +} + +/** 扫描进度回调 */ +export type ScanProgressCallback = (progress: { found: number; currentPath: string }) => void; + +/** 扫描结果条目(文件元信息) */ +export interface ScanEntry { + /** 文件绝对路径(离线模式)/ 文件 URI(在线模式) */ + path: string; + /** 文件名(不含路径) */ + fileName: string; + /** 扩展名(小写,不含点) */ + ext: string; + /** 文件大小(字节) */ + fileSize: number; + /** 修改时间 ISO 字符串 */ + modifiedAt: string; +} + +// ---------- 侧加载元数据 ---------- + +/** 旁加载元数据 JSON(sidecar 文件) */ +export interface SidecarMetadata { + schema_version: number; + favorite: boolean; + remark: string; + description: string; + tags: string[]; + create_time: string; + original_name: string; + /** 审核状态(仅 Storyboard / Keyframe / Video 阶段有效) */ + audit_status?: 'pending' | 'approved' | 'rework' | 'deprecated'; +} + +/** 默认 sidecar 元数据 */ +export function getDefaultSidecarMetadata(fileName: string): SidecarMetadata { + return { + schema_version: 1, + favorite: false, + remark: '', + description: '', + tags: [], + create_time: new Date().toISOString().replace('T', ' ').slice(0, 19), + original_name: fileName, + audit_status: 'pending', + }; +} + +// ---------- 缩略图 ---------- + +/** 缩略图请求参数 */ +export interface ThumbnailRequest { + /** 源文件绝对路径 */ + filePath: string; + /** 目标尺寸(像素,按长边等比缩放) */ + size: 512 | 1024 | 2048 | 4096; +} + +/** 缩略图生成结果 */ +export interface ThumbnailResult { + /** Base64 编码的图片数据 */ + data: string; + /** MIME 类型(如 "image/jpeg") */ + mimeType: string; + /** 原始宽度 */ + width: number; + /** 原始高度 */ + height: number; +} + +// ---------- 数据源能力标志 ---------- + +/** 数据源能力标志 */ +export interface DataSourceCapabilities { + /** 是否支持离线操作 */ + offline: boolean; + /** 是否支持实时协作(WebSocket 同步) */ + realtime: boolean; + /** 是否支持服务器端缩略图生成 */ + thumbnails: boolean; +} + +// ---------- 主接口 ---------- + +/** + * 画布数据源接口 + * + * 所有文件系统和 API 操作都通过此接口,确保: + * 1. 上层 Store 和组件不直接依赖 IPC 或 HTTP + * 2. 离线 / 在线模式可平滑切换 + * 3. 测试时可用 Mock 实现替换 + */ +export interface ICanvasDataSource { + /** 数据源能力标志(只读) */ + readonly capabilities: DataSourceCapabilities; + + /** 扫描目录,返回匹配文件列表 */ + scanDirectory( + request: ScanRequest, + onProgress?: ScanProgressCallback, + ): Promise; + + /** + * 扫描项目(推荐方式)。 + * 接收 projectPath + 筛选条件,主进程内部完成目录探索 + 文件遍历。 + * 渲染进程无需直接访问 fs 模块。 + */ + scanProject(params: { + projectPath: string; + stage: string; + assetTypeFilter: string; + shotQuery: string; + }): Promise; + + /** 读取文件的旁加载元数据(sidecar JSON) */ + readSidecar(filePath: string): Promise; + + /** 写入文件的旁加载元数据 */ + writeSidecar(filePath: string, metadata: SidecarMetadata): Promise; + + /** 生成缩略图(返回 base64 data URL) */ + generateThumbnail(request: ThumbnailRequest): Promise; + + /** 获取文件二进制数据(base64 编码) */ + readFileData(filePath: string): Promise; + + /** 获取文件访问 URL(用于 /