feat: 无限画布滚轮缩放 + 项目管理入口 (0.0.26)

画布交互:
- 滚轮缩放视口(2%~1600%),纯滚轮 10%/tick,Ctrl/Cmd+滚轮 3%/tick 精细控制
- 鼠标拖拽平移画布,Ctrl/Cmd+0 重置缩放
- 预览打开时锁定底层视口(previewActiveRef 事件穿透防护)
- 预览内图片独立滚轮缩放 + 拖拽平移 + 缩放指示器

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

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

主进程(electron/main.ts):
- 画布窗口 before-input-event 拦截 Chromium 页面缩放
- 新增 canvas-ipc.ts(startDrag、文件扫描等 IPC handler)
This commit is contained in:
2026-06-23 20:11:50 +08:00
parent dd8f84ae27
commit 4ea414c9c7
38 changed files with 5193 additions and 17 deletions

View File

@@ -0,0 +1,252 @@
// ============================================================
// scanner.ts — 文件扫描算法
//
// 基于 infinite_canvas_140.md §2.2-2.3 的分析实现:
// - detectStage(filePath) — 从路径检测阶段
// - groupKeyForPath(filePath, stage) — 提取分组键
// - buildScanRoots(...) — 计算扫描根目录
// - getStageExtensions(stage) — 阶段对应的文件扩展名
// - shotNameMatchesQuery(shotName, query) — 镜头名称匹配
// ============================================================
import type { StageType } from '../store/useCanvasStore';
// ---------- 阶段检测 ----------
/** 阶段映射:路径关键词 → StageType */
const STAGE_PATTERNS: Array<{ segment: string; stage: StageType }> = [
{ segment: 'Storyboard', stage: 'Storyboard' },
{ segment: 'Keyframe', stage: 'Keyframe' },
{ segment: 'Video', stage: 'Video' },
{ segment: 'Audio', stage: 'Audio' },
{ segment: 'LipSync', stage: 'LipSync' },
];
/**
* 从文件路径中检测所属阶段。
* 查找路径中是否包含阶段目录名(如 "/Storyboard/")。
* Assets 阶段特殊处理:路径中含有 "/assets/" 即为 Assets。
*/
export function detectStage(filePath: string): StageType {
const normalized = filePath.replace(/\\/g, '/');
for (const { segment, stage } of STAGE_PATTERNS) {
if (normalized.includes(`/${segment}/`)) {
return stage;
}
}
if (normalized.includes('/assets/')) {
return 'Assets';
}
return 'Storyboard'; // 默认
}
// ---------- 分组键提取 ----------
/**
* 从文件路径中提取分组键。
*
* Assets 阶段:
* 向上找到 assets/ 目录 → 下一层为 typechr/prp/set
* → 再下一层为 name角色名/道具名/场景名)
* → 返回 "chr/主角A"
*
* 非 Assets 阶段:
* 向上找到 shots/ 目录 → 下一层为 episodeE01
* → 再下一层为 shotSC001
* → 返回 "E01/SC001"
*/
export function groupKeyForPath(filePath: string, stage: StageType): string {
const normalized = filePath.replace(/\\/g, '/');
if (stage === 'Assets') {
const parts = normalized.split('/');
const assetsIdx = parts.lastIndexOf('assets');
if (assetsIdx >= 0 && assetsIdx + 2 < parts.length) {
const assetType = parts[assetsIdx + 1]; // chr / prp / set
const assetName = parts[assetsIdx + 2]; // 角色名 / 道具名 / 场景名
return `${assetType}/${assetName}`;
}
return 'assets/unknown';
}
// 非 Assets查找 shots/ 目录
const parts = normalized.split('/');
const shotsIdx = parts.lastIndexOf('shots');
if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) {
const episode = parts[shotsIdx + 1]; // E01
const shot = parts[shotsIdx + 2]; // SC001
return `${episode}/${shot}`;
}
return 'unknown/unknown';
}
/** 从路径中提取集数名(如 "E01" */
export function extractEpisodeName(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const parts = normalized.split('/');
const shotsIdx = parts.lastIndexOf('shots');
if (shotsIdx >= 0 && shotsIdx + 1 < parts.length) {
return parts[shotsIdx + 1];
}
return '';
}
/** 从路径中提取镜头名(如 "SC001" */
export function extractShotName(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const parts = normalized.split('/');
const shotsIdx = parts.lastIndexOf('shots');
if (shotsIdx >= 0 && shotsIdx + 2 < parts.length) {
return parts[shotsIdx + 2];
}
return '';
}
// ---------- 扫描根目录计算 ----------
/** 资产类型筛选 */
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
/** buildScanRoots 参数 */
export interface BuildScanRootsParams {
projectPath: string;
episodePath: string | null;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
}
/**
* 计算文件扫描的根目录列表。
*
* Assets 阶段:
* "全部" → [project/assets/]
* "角色" → [project/assets/chr/]
* "道具" → [project/assets/prp/]
* "场景" → [project/assets/set/]
*
* 非 Assets 阶段:
* 遍历 project/shots/ 下的所有集数目录,
* 对每个集数遍历所有 shot 子目录,
* 追加 {shotDir}/{stage}/
* 若指定 shotQuery → 仅匹配的镜头
*/
export function buildScanRoots(params: BuildScanRootsParams): string[] {
const { projectPath, episodePath, stage, assetTypeFilter, shotQuery } = params;
if (stage === 'Assets') {
const assetsDir = `${projectPath}/assets`;
switch (assetTypeFilter) {
case '角色':
return [`${assetsDir}/chr`];
case '道具':
return [`${assetsDir}/prp`];
case '场景':
return [`${assetsDir}/set`];
default:
return [assetsDir];
}
}
// 非 Assets遍历 shots/ 目录
const shotsDir = `${projectPath}/shots`;
const fs = requireNodeFs();
if (!fs || !fs.existsSync(shotsDir)) return [];
const roots: string[] = [];
const episodes = episodePath
? [episodePath]
: fs.readdirSync(shotsDir).filter((name: string) => {
const full = `${shotsDir}/${name}`;
return (
!name.startsWith('.') &&
fs.statSync(full).isDirectory()
);
});
for (const ep of episodes) {
const epDir = episodePath || `${shotsDir}/${ep}`;
if (!fs.existsSync(epDir)) continue;
const shotNames = fs.readdirSync(epDir).filter((name: string) => {
const full = `${epDir}/${name}`;
return (
!name.startsWith('.') &&
fs.statSync(full).isDirectory()
);
});
for (const shot of shotNames) {
// shotQuery 过滤
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
const stageDir = `${epDir}/${shot}/${stage}`;
if (fs.existsSync(stageDir)) {
roots.push(stageDir);
}
}
}
return roots;
}
// ---------- Shot 名称匹配 ----------
/**
* 镜头名称匹配算法§11.3)。
*
* - query 为空 → 匹配所有
* - query.toUpperCase() in shotName.toUpperCase() → 匹配
* - query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
*
* 例query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
*/
export 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;
// 纯数字匹配:提取 shotName 中的所有数字位
if (/^\d+$/.test(query)) {
const digits = upperShot.replace(/\D/g, '');
if (digits.includes(query)) return true;
}
return false;
}
// ---------- 阶段扩展名映射 ----------
/**
* 每个阶段允许的文件扩展名(不含点,小写)。
*/
export 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'];
}
}
// ---------- 内部工具 ----------
/**
* 尝试获取 Node.js fs 模块(仅在 Electron 环境可用)。
* 渲染进程中通过 vite-plugin-electron-renderer 暴露。
* 注意:此处仅用于 buildScanRoots 中的同步读取目录结构(低频操作)。
* 大量文件扫描走 IPC → 主进程。
*/
function requireNodeFs(): typeof import('fs') | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('fs');
} catch {
return null;
}
}