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:
@@ -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,
|
||||
|
||||
32
src/main.tsx
32
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(
|
||||
<React.StrictMode>
|
||||
{/* AppProvider — 全局应用状态(平台/版本/登录态) */}
|
||||
<AppProvider>
|
||||
{/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */}
|
||||
{isCanvasMode ? (
|
||||
// Canvas 模式:轻量级 Provider,无 Auth/Storage/Settings 依赖
|
||||
<ThemeProvider>
|
||||
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
|
||||
<SettingsProvider>
|
||||
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</SettingsProvider>
|
||||
<AntdApp>
|
||||
<CanvasApp />
|
||||
</AntdApp>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
) : (
|
||||
// 正常模式:完整的 AppProvider 链
|
||||
<AppProvider>
|
||||
<ThemeProvider>
|
||||
<SettingsProvider>
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</SettingsProvider>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
)}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
93
src/pages/infinite-canvas/CanvasApp.tsx
Normal file
93
src/pages/infinite-canvas/CanvasApp.tsx
Normal file
@@ -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 (
|
||||
<CanvasStoreProvider>
|
||||
<WorkspaceInitializer />
|
||||
<CanvasPage />
|
||||
</CanvasStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部组件:工作区初始化后自动加载项目列表。
|
||||
* 放在 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;
|
||||
}
|
||||
225
src/pages/infinite-canvas/CanvasPage.tsx
Normal file
225
src/pages/infinite-canvas/CanvasPage.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>{icon}</div>
|
||||
<div style={{ fontSize: 14, color: isDark ? '#A5A3C0' : '#6B7280' }}>{title}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||
}}
|
||||
>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Placeholder
|
||||
icon="📂"
|
||||
title="请先选择一个工作区目录"
|
||||
hint="目录结构需符合 AIGC 生产管线约定(项目/shots/集数/镜头/阶段)"
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<Placeholder
|
||||
icon="📁"
|
||||
title="请先选择一个项目"
|
||||
hint="从顶部工具栏的项目下拉列表中选择"
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isScanning) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔍</div>
|
||||
<div style={{ fontSize: 14 }}>正在扫描文件…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAssets && !selectedEpisode) {
|
||||
return (
|
||||
<Placeholder
|
||||
icon="🎬"
|
||||
title="请先选择集数"
|
||||
hint="从顶部工具栏的集数下拉列表中选择"
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAssets && !selectedShot) {
|
||||
return (
|
||||
<Placeholder
|
||||
icon="🎞️"
|
||||
title="请先选择镜头"
|
||||
hint="从左侧镜头列表中点击选择"
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Placeholder
|
||||
icon="📭"
|
||||
title={`当前阶段「${currentStage}」没有匹配的文件`}
|
||||
hint="请确认目录结构,或切换阶段标签"
|
||||
isDark={isDark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <CanvasViewport />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: isDark ? '#0F0D1E' : '#F5F3FF',
|
||||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<CanvasToolbar />
|
||||
|
||||
{/* 中心区域:两栏布局 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 左侧镜头列表(Assets 阶段隐藏) */}
|
||||
<ShotList />
|
||||
|
||||
{/* 右侧主内容 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
<div
|
||||
style={{
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 12px',
|
||||
borderTop: `1px solid ${isDark ? '#2E2A5A' : '#E5E7EB'}`,
|
||||
background: isDark ? '#15132E' : '#EEF2FF',
|
||||
fontSize: 11,
|
||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||
}}
|
||||
>
|
||||
<span>离线模式 · 本地文件系统</span>
|
||||
<span style={{ margin: '0 8px' }}>|</span>
|
||||
<span>
|
||||
{isScanning
|
||||
? '扫描中…'
|
||||
: workspaceRoot
|
||||
? `项目:${selectedProject || '(未选)'} · 阶段:${currentStage} · ${items.length} 个文件`
|
||||
: '准备就绪'}
|
||||
</span>
|
||||
{selectedEpisode && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>|</span>
|
||||
<span>集数:{selectedEpisode}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedShot && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>|</span>
|
||||
<span>镜头:{selectedShot}</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span>Phase 1</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
src/pages/infinite-canvas/core/CanvasViewport.tsx
Normal file
367
src/pages/infinite-canvas/core/CanvasViewport.tsx
Normal file
@@ -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<HTMLDivElement>(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<number | null>(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<number, { x: number; y: number }>();
|
||||
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<number, string>(),
|
||||
};
|
||||
}
|
||||
// single mode
|
||||
const layout = singleLayout(pageItems);
|
||||
// 构建行标签映射
|
||||
const labels = new Map<number, string>();
|
||||
const seen = new Set<string>();
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
cursor: isDragging
|
||||
? 'grabbing'
|
||||
: interactionMode === 'SELECT'
|
||||
? 'default'
|
||||
: 'grab',
|
||||
background: isDark ? '#0F0D1E' : '#F9FAFB',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{/* 画布内容 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
transform,
|
||||
transformOrigin: '0 0',
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
}}
|
||||
>
|
||||
{/* Single 模式的行标签 */}
|
||||
{layoutMode === 'single' &&
|
||||
Array.from(rowLabels.entries()).map(([row, label]) => (
|
||||
<div
|
||||
key={`label-${row}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: row * (ITEM_H + ITEM_SPACING),
|
||||
width: ROW_LABEL_WIDTH,
|
||||
height: ITEM_H,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: isDark ? '#C4B5FD' : '#6366F1',
|
||||
background: isDark
|
||||
? 'rgba(30, 27, 75, 0.6)'
|
||||
: 'rgba(238, 242, 255, 0.8)',
|
||||
borderRadius: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* MediaCard 渲染(受硬上限保护) */}
|
||||
{pageItems.slice(0, MAX_ITEMS_PER_PAGE).map((item) => {
|
||||
const pos = positions.get(item.id);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<MediaCardContextMenu key={item.id} itemId={item.id} x={pos.x} y={pos.y}>
|
||||
<MediaCard
|
||||
itemId={item.id}
|
||||
x={0}
|
||||
y={0}
|
||||
zoom={zoom}
|
||||
mode={interactionMode}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
</MediaCardContextMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* DOM 硬上限警告 */}
|
||||
{pageItems.length > MAX_ITEMS_PER_PAGE && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: canvasHeight + 16,
|
||||
left: 0,
|
||||
width: canvasWidth,
|
||||
padding: '8px 16px',
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 77, 79, 0.1)',
|
||||
border: '1px solid rgba(255, 77, 79, 0.3)',
|
||||
color: '#FF4D4F',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
⚠️ 当前页共 {pageItems.length} 个文件,已渲染前 {MAX_ITEMS_PER_PAGE} 个。
|
||||
请切换为更小的页面尺寸或使用筛选。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空画布提示 */}
|
||||
{pageItems.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#9CA3AF',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
无匹配文件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 浮层控件 */}
|
||||
<PagerOverlay />
|
||||
<ZoomOverlay />
|
||||
<ModeSwitch />
|
||||
|
||||
{/* 全屏预览(双击打开) */}
|
||||
<MediaPreview
|
||||
itemId={previewItemId}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/pages/infinite-canvas/core/LODImage.tsx
Normal file
144
src/pages/infinite-canvas/core/LODImage.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 24,
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
🖼️
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
// 棋盘格背景
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)
|
||||
`,
|
||||
backgroundSize: '16px 16px',
|
||||
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
opacity: loading ? 0.3 : 1,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
src/pages/infinite-canvas/core/MediaCard.tsx
Normal file
187
src/pages/infinite-canvas/core/MediaCard.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: ITEM_W,
|
||||
height: ITEM_H,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
cursor: mode === 'SELECT' ? 'pointer' : 'default',
|
||||
border: isSelected
|
||||
? '2px solid #6366F1'
|
||||
: '1px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: isSelected
|
||||
? '0 0 8px rgba(99, 102, 241, 0.4)'
|
||||
: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
transition: 'border 0.1s, box-shadow 0.1s',
|
||||
background: '#fff',
|
||||
}}
|
||||
draggable={mode === 'BROWSE'}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* 缩略图区域 */}
|
||||
<div style={{ width: ITEM_W, height: ITEM_H - 22, position: 'relative' }}>
|
||||
{isVideo || isAudio ? (
|
||||
/* 视频/音频:显示图标 */
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 32,
|
||||
background: '#f0f0f0',
|
||||
}}
|
||||
>
|
||||
{isVideo ? '🎥' : '🎵'}
|
||||
</div>
|
||||
) : (
|
||||
<LODImage itemId={itemId} filePath={item.path} targetLevel={lodIndex} />
|
||||
)}
|
||||
|
||||
{/* 审核角标 */}
|
||||
{auditStatus && auditColor && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: auditColor,
|
||||
border: '1px solid rgba(255,255,255,0.8)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
title={auditStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 选中勾选(SELECT 模式) */}
|
||||
{mode === 'SELECT' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 3,
|
||||
background: isSelected ? '#6366F1' : 'rgba(255,255,255,0.8)',
|
||||
border: `2px solid ${isSelected ? '#6366F1' : '#d1d5db'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{isSelected ? '✓' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件名标签 */}
|
||||
<div
|
||||
style={{
|
||||
height: 22,
|
||||
padding: '0 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 10,
|
||||
color: '#6B7280',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
background: '#FAFAFA',
|
||||
borderTop: '1px solid #F0F0F0',
|
||||
}}
|
||||
title={item.fileName}
|
||||
>
|
||||
{item.fileName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
163
src/pages/infinite-canvas/core/MediaCardContextMenu.tsx
Normal file
163
src/pages/infinite-canvas/core/MediaCardContextMenu.tsx
Normal file
@@ -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: <FolderOpenOutlined />,
|
||||
onClick: () => void handleShowInFolder(),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
label: '复制文件路径',
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => void handleCopyPath(),
|
||||
},
|
||||
...(isAuditable
|
||||
? [
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'audit-header',
|
||||
label: '审核状态',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'audit-pending',
|
||||
label: '⏳ 待审核',
|
||||
icon: <CheckCircleOutlined style={{ color: '#FAAD14' }} />,
|
||||
onClick: () => handleSetAudit('pending'),
|
||||
},
|
||||
{
|
||||
key: 'audit-approved',
|
||||
label: '✅ 已通过',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52C41A' }} />,
|
||||
onClick: () => handleSetAudit('approved'),
|
||||
},
|
||||
{
|
||||
key: 'audit-rework',
|
||||
label: '🔄 需返工',
|
||||
icon: <CloseCircleOutlined style={{ color: '#FF4D4F' }} />,
|
||||
onClick: () => handleSetAudit('rework'),
|
||||
},
|
||||
{
|
||||
key: 'audit-deprecated',
|
||||
label: '🗑️ 已废弃',
|
||||
icon: <CloseCircleOutlined style={{ color: '#8C8C8C' }} />,
|
||||
onClick: () => handleSetAudit('deprecated'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除文件',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: ITEM_W,
|
||||
height: ITEM_H,
|
||||
}}
|
||||
>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
src/pages/infinite-canvas/core/MediaPreview.tsx
Normal file
345
src/pages/infinite-canvas/core/MediaPreview.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
// ============================================================
|
||||
// MediaPreview — 全屏/灯箱预览
|
||||
//
|
||||
// 双击 MediaCard 时弹出,显示大图/视频/音频。
|
||||
// - 图片:全屏显示原图(4096px LOD)
|
||||
// - 视频:HTML5 <video> 播放器
|
||||
// - 音频:HTML5 <audio> 播放器
|
||||
//
|
||||
// 文件加载:通过 local-media:// 自定义协议加载本地文件,
|
||||
// 主进程 protocol.handle 拦截并返回文件内容。
|
||||
// ============================================================
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
|
||||
interface MediaPreviewProps {
|
||||
itemId: number | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 将文件路径转为 local-media:// URL(通过自定义协议安全加载本地文件) */
|
||||
function toMediaUrl(filePath: string): string {
|
||||
// 优先通过 preload 注入的 canvas.getMediaUrl
|
||||
if (typeof window.canvas?.getMediaUrl === 'function') {
|
||||
return window.canvas.getMediaUrl(filePath);
|
||||
}
|
||||
// 回退(非 Electron 环境,一般不可用)
|
||||
return `file://${filePath.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
export function MediaPreview({ itemId, onClose }: MediaPreviewProps) {
|
||||
const items = useCanvasStore((s) => s.items);
|
||||
const [currentId, setCurrentId] = useState(itemId);
|
||||
|
||||
// 同步外部 itemId 变化
|
||||
useEffect(() => {
|
||||
setCurrentId(itemId);
|
||||
}, [itemId]);
|
||||
|
||||
// ⚠️ useMemo 必须在所有条件 return 之前调用(Rules of Hooks)
|
||||
const navigate = useMemo(() => {
|
||||
return (direction: -1 | 1) => {
|
||||
const idx = items.findIndex((i) => i.id === currentId);
|
||||
const next = idx + direction;
|
||||
if (next >= 0 && next < items.length) {
|
||||
setCurrentId(items[next].id);
|
||||
}
|
||||
};
|
||||
}, [items, currentId]);
|
||||
|
||||
// ── 缩放 & 拖拽状态 ──
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
const [previewPan, setPreviewPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const imgContainerRef = useRef<HTMLDivElement>(null);
|
||||
const panStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
// ref 版 zoom(供稳定闭包的 native wheel listener 读取)
|
||||
const previewZoomRef = useRef(previewZoom);
|
||||
useEffect(() => { previewZoomRef.current = previewZoom; }, [previewZoom]);
|
||||
|
||||
// 切换图片时重置缩放/平移
|
||||
useEffect(() => {
|
||||
setPreviewZoom(1);
|
||||
setPreviewPan({ x: 0, y: 0 });
|
||||
}, [currentId]);
|
||||
|
||||
// 预览内滚轮缩放(native listener + stopPropagation 隔离)
|
||||
useEffect(() => {
|
||||
const el = imgContainerRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 阻止冒泡到 CanvasViewport
|
||||
const delta = -e.deltaY * 0.002;
|
||||
const cur = previewZoomRef.current;
|
||||
const newZoom = Math.max(0.1, Math.min(8.0, cur + delta));
|
||||
setPreviewZoom(newZoom);
|
||||
};
|
||||
el.addEventListener('wheel', handler, { passive: false });
|
||||
return () => el.removeEventListener('wheel', handler);
|
||||
}, []);
|
||||
|
||||
// ── 拖拽平移(仅在预览内)──
|
||||
const handlePreviewMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
e.stopPropagation(); // 阻止冒泡到 CanvasViewport
|
||||
setIsPanning(true);
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: previewPan.x,
|
||||
panY: previewPan.y,
|
||||
};
|
||||
}, [previewPan]);
|
||||
|
||||
const handlePreviewMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
e.stopPropagation();
|
||||
const dx = e.clientX - panStartRef.current.x;
|
||||
const dy = e.clientY - panStartRef.current.y;
|
||||
setPreviewPan({
|
||||
x: panStartRef.current.panX + dx,
|
||||
y: panStartRef.current.panY + dy,
|
||||
});
|
||||
}, [isPanning]);
|
||||
|
||||
const handlePreviewMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// 全局 mouseup(拖拽到预览外释放)
|
||||
useEffect(() => {
|
||||
const handler = () => setIsPanning(false);
|
||||
window.addEventListener('mouseup', handler);
|
||||
return () => window.removeEventListener('mouseup', handler);
|
||||
}, []);
|
||||
|
||||
if (currentId == null) return null;
|
||||
|
||||
const currentItem = items.find((i) => i.id === currentId);
|
||||
if (!currentItem) return null;
|
||||
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'].includes(
|
||||
currentItem.ext,
|
||||
);
|
||||
const isVideo = ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(
|
||||
currentItem.ext,
|
||||
);
|
||||
const isAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(
|
||||
currentItem.ext,
|
||||
);
|
||||
|
||||
const mediaUrl = toMediaUrl(currentItem.path);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0, 0, 0, 0.92)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* 顶栏 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#fff', fontSize: 13, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{currentItem.fileName}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 48 }}>
|
||||
{isImage && (
|
||||
<div
|
||||
ref={imgContainerRef}
|
||||
style={{
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: isPanning ? 'grabbing' : previewZoom > 1 ? 'grab' : 'default',
|
||||
}}
|
||||
onMouseDown={handlePreviewMouseDown}
|
||||
onMouseMove={handlePreviewMouseMove}
|
||||
onMouseUp={handlePreviewMouseUp}
|
||||
>
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={currentItem.fileName}
|
||||
draggable={false}
|
||||
style={{
|
||||
maxWidth: previewZoom === 1 ? '100%' : 'none',
|
||||
maxHeight: previewZoom === 1 ? '100%' : 'none',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 4,
|
||||
transform: `translate(${previewPan.x}px, ${previewPan.y}px) scale(${previewZoom})`,
|
||||
transformOrigin: 'center center',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
// 预留:naturalWidth/Height 可用于智能初始缩放
|
||||
void e.currentTarget.naturalWidth;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isVideo && (
|
||||
<video
|
||||
src={mediaUrl}
|
||||
controls
|
||||
autoPlay
|
||||
style={{ maxWidth: '90vw', maxHeight: '85vh', borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div style={{ textAlign: 'center', color: '#fff' }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>🎵</div>
|
||||
<div style={{ fontSize: 16, marginBottom: 12 }}>{currentItem.fileName}</div>
|
||||
<audio src={mediaUrl} controls autoPlay />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
width: 48,
|
||||
height: 48,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => navigate(1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
width: 48,
|
||||
height: 48,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 缩放指示器(仅图片模式显示) */}
|
||||
{isImage && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 48,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
disabled={previewZoom <= 0.1}
|
||||
onClick={() => setPreviewZoom(Math.max(0.1, previewZoom - 0.25))}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
minWidth: 44,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => {
|
||||
setPreviewZoom(1);
|
||||
setPreviewPan({ x: 0, y: 0 });
|
||||
}}
|
||||
title="重置缩放 (100%)"
|
||||
>
|
||||
{Math.round(previewZoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={previewZoom >= 8.0}
|
||||
onClick={() => setPreviewZoom(Math.min(8.0, previewZoom + 0.25))}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底栏信息 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#aaa',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{currentItem.groupKey} · {currentItem.stage} · {items.findIndex((i) => i.id === currentId) + 1} / {items.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
src/pages/infinite-canvas/core/ViewportControls.tsx
Normal file
217
src/pages/infinite-canvas/core/ViewportControls.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 36,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 20,
|
||||
background: isDark ? 'rgba(30, 27, 75, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
border: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => gotoPage(currentPage - 1)}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||
minWidth: 80,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => gotoPage(currentPage + 1)}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||
borderLeft: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||||
paddingLeft: 8,
|
||||
}}
|
||||
>
|
||||
{items.length} 项 · {perPageText}/页
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 缩放控件 ──
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 36,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: '2px 4px',
|
||||
borderRadius: 8,
|
||||
background: isDark ? 'rgba(30, 27, 75, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
border: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
disabled={zoom <= 0.02}
|
||||
onClick={() => setZoom(zoom - 0.1)}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||
minWidth: 44,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setZoom(1.0)}
|
||||
title="重置为 100%"
|
||||
>
|
||||
{zoomPercent}%
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={zoom >= 16.0}
|
||||
onClick={() => setZoom(zoom + 0.1)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ExpandOutlined />}
|
||||
onClick={() => setZoom(1.0)}
|
||||
title="重置为 100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 模式切换 ──
|
||||
|
||||
export function ModeSwitch() {
|
||||
const { isDark } = useTheme();
|
||||
const { interactionMode, switchMode } = useCanvasInteraction();
|
||||
|
||||
const modes = [
|
||||
{ key: 'BROWSE' as const, icon: <EyeOutlined />, label: '浏览', shortcut: 'V' },
|
||||
{ key: 'SELECT' as const, icon: <CheckSquareOutlined />, label: '选择', shortcut: 'S' },
|
||||
{ key: 'REORDER' as const, icon: <MenuOutlined />, label: '排序', shortcut: 'R' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 36,
|
||||
right: 160,
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
padding: '2px 4px',
|
||||
borderRadius: 8,
|
||||
background: isDark ? 'rgba(30, 27, 75, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
border: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
{modes.map(({ key, icon, label, shortcut }) => (
|
||||
<Button
|
||||
key={key}
|
||||
type={interactionMode === key ? 'primary' : 'text'}
|
||||
size="small"
|
||||
icon={icon}
|
||||
onClick={() => switchMode(key)}
|
||||
title={`${label}模式 (${shortcut})`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: interactionMode === key ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginLeft: 2, fontSize: 10 }}>{shortcut}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/pages/infinite-canvas/core/index.ts
Normal file
11
src/pages/infinite-canvas/core/index.ts
Normal file
@@ -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';
|
||||
7
src/pages/infinite-canvas/hooks/index.ts
Normal file
7
src/pages/infinite-canvas/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ============================================================
|
||||
// hooks — 无限画布自定义 Hook
|
||||
// ============================================================
|
||||
|
||||
export { useDirectoryScan } from './useDirectoryScan';
|
||||
export { useCanvasInteraction } from './useCanvasInteraction';
|
||||
export { useSidecarSync } from './useSidecarSync';
|
||||
72
src/pages/infinite-canvas/hooks/useCanvasInteraction.ts
Normal file
72
src/pages/infinite-canvas/hooks/useCanvasInteraction.ts
Normal file
@@ -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 };
|
||||
}
|
||||
163
src/pages/infinite-canvas/hooks/useDirectoryScan.ts
Normal file
163
src/pages/infinite-canvas/hooks/useDirectoryScan.ts
Normal file
@@ -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<AbortController | null>(null);
|
||||
// 防抖 timer
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 };
|
||||
}
|
||||
59
src/pages/infinite-canvas/hooks/useSidecarSync.ts
Normal file
59
src/pages/infinite-canvas/hooks/useSidecarSync.ts
Normal file
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<Map<number, SidecarMetadata>>(new Map());
|
||||
|
||||
/**
|
||||
* 提交元数据更新(250ms 防抖)。
|
||||
* 更新 Store → 延迟写入 sidecar JSON(失败时保留 Store 中的副本)。
|
||||
*/
|
||||
const commitMetadata = useCallback(
|
||||
(itemId: number, filePath: string, partial: Partial<SidecarMetadata>) => {
|
||||
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 };
|
||||
}
|
||||
39
src/pages/infinite-canvas/navigation/ContextMenu.tsx
Normal file
39
src/pages/infinite-canvas/navigation/ContextMenu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// ============================================================
|
||||
// ContextMenu — 可复用右键菜单封装
|
||||
//
|
||||
// 用法:
|
||||
// <ContextMenu items={[{ label: '新建项目', onClick: () => ... }]}>
|
||||
// <YourComponent />
|
||||
// </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 (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// ============================================================
|
||||
// DirectoryCreateModal — 可复用新建目录弹窗
|
||||
//
|
||||
// 用法:
|
||||
// <DirectoryCreateModal
|
||||
// open={open}
|
||||
// title="新建项目"
|
||||
// onOk={(name) => 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<string | null>(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 (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入目录名称"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onPressEnter={handleOk}
|
||||
status={error ? 'error' : undefined}
|
||||
/>
|
||||
{error && <div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>{error}</div>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
103
src/pages/infinite-canvas/navigation/EpisodeSelector.tsx
Normal file
103
src/pages/infinite-canvas/navigation/EpisodeSelector.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: '新建集数',
|
||||
onClick: () => setCreateOpen(true),
|
||||
disabled: !selectedProject,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
value={selectedEpisode}
|
||||
onChange={handleSelect}
|
||||
placeholder="选择集数"
|
||||
size="small"
|
||||
style={{ width: 120 }}
|
||||
options={options}
|
||||
disabled={!selectedProject}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<DirectoryCreateModal
|
||||
open={createOpen}
|
||||
title="新建集数"
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
src/pages/infinite-canvas/navigation/ProjectSelector.tsx
Normal file
96
src/pages/infinite-canvas/navigation/ProjectSelector.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// ============================================================
|
||||
// ProjectSelector — 项目下拉选择器
|
||||
//
|
||||
// 列出 workspaceRoot 下的子文件夹作为可选项目。
|
||||
// 右击可新建项目。
|
||||
// ============================================================
|
||||
|
||||
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 ProjectSelector() {
|
||||
const { message } = App.useApp();
|
||||
const workspaceRoot = useCanvasStore((s) => s.workspaceRoot);
|
||||
const projects = useCanvasStore((s) => s.projects);
|
||||
const selectedProject = useCanvasStore((s) => s.selectedProject);
|
||||
const selectProject = useCanvasStore((s) => s.selectProject);
|
||||
const setEpisodes = useCanvasStore((s) => s.setEpisodes);
|
||||
const setProjects = useCanvasStore((s) => s.setProjects);
|
||||
const getDataSource = useCanvasStore((s) => s.getDataSource);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (name: string) => {
|
||||
selectProject(name);
|
||||
// 异步加载该项目的集数列表
|
||||
try {
|
||||
const dataSource = getDataSource();
|
||||
const epPath = `${workspaceRoot}\\${name}\\shots`;
|
||||
const episodes = await dataSource.listSubdirs(epPath);
|
||||
setEpisodes(episodes);
|
||||
} catch (err) {
|
||||
console.error('[ProjectSelector] 加载集数列表失败:', err);
|
||||
}
|
||||
},
|
||||
[workspaceRoot, selectProject, setEpisodes, getDataSource],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (dirName: string) => {
|
||||
if (!workspaceRoot) return;
|
||||
try {
|
||||
const dataSource = getDataSource();
|
||||
await dataSource.createDirectory(workspaceRoot, dirName);
|
||||
// 刷新项目列表
|
||||
const updated = await dataSource.listSubdirs(workspaceRoot);
|
||||
setProjects(updated);
|
||||
// 自动选中新项目
|
||||
await handleSelect(dirName);
|
||||
message.success(`项目 "${dirName}" 已创建`);
|
||||
} catch (err) {
|
||||
message.error(`创建失败: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
[workspaceRoot, getDataSource, setProjects, handleSelect, message],
|
||||
);
|
||||
|
||||
const options = projects.map((p) => ({ value: p, label: p }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: '新建项目',
|
||||
onClick: () => setCreateOpen(true),
|
||||
disabled: !workspaceRoot,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
value={selectedProject}
|
||||
onChange={handleSelect}
|
||||
placeholder="选择项目"
|
||||
size="small"
|
||||
style={{ width: 140 }}
|
||||
options={options}
|
||||
disabled={!workspaceRoot}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<DirectoryCreateModal
|
||||
open={createOpen}
|
||||
title="新建项目"
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
src/pages/infinite-canvas/navigation/ShotList.tsx
Normal file
126
src/pages/infinite-canvas/navigation/ShotList.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
width: SHOT_LIST_WIDTH,
|
||||
flexShrink: 0,
|
||||
background: bg,
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: textColor,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
镜头列表
|
||||
{shots.length > 0 && (
|
||||
<span style={{ fontWeight: 400, marginLeft: 4 }}>({shots.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{!selectedEpisode ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 12px',
|
||||
fontSize: 12,
|
||||
color: textColor,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
请先选择集数
|
||||
</div>
|
||||
) : shots.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 12px',
|
||||
fontSize: 12,
|
||||
color: textColor,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
该集数下无镜头
|
||||
</div>
|
||||
) : (
|
||||
shots.map((shot) => {
|
||||
const active = shot === selectedShot;
|
||||
return (
|
||||
<div
|
||||
key={shot}
|
||||
onClick={() => 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}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/pages/infinite-canvas/navigation/index.ts
Normal file
10
src/pages/infinite-canvas/navigation/index.ts
Normal file
@@ -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';
|
||||
384
src/pages/infinite-canvas/store/useCanvasStore.ts
Normal file
384
src/pages/infinite-canvas/store/useCanvasStore.ts
Normal file
@@ -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<number>;
|
||||
|
||||
// ── 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<SidecarMetadata>) => void;
|
||||
loadThumbnail: (id: number, blobUrl: string, level: number) => void;
|
||||
/** 获取 dataSource(供 hooks 使用) */
|
||||
getDataSource: () => ICanvasDataSource;
|
||||
}
|
||||
|
||||
// ---------- Store 逻辑工厂 ----------
|
||||
|
||||
function createCanvasStoreLogic(dataSource: ICanvasDataSource, initialWorkspace: string | null) {
|
||||
return createStore<CanvasState>()((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<CanvasState>),
|
||||
|
||||
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<typeof createCanvasStoreLogic>;
|
||||
|
||||
/** 模块级 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<CanvasStore | null>(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<T>(selector: (state: CanvasState) => T): T {
|
||||
const store = useContext(CanvasStoreContext);
|
||||
if (!store) {
|
||||
throw new Error('useCanvasStore 必须在 CanvasStoreProvider 内部使用');
|
||||
}
|
||||
return useStore(store, selector);
|
||||
}
|
||||
89
src/pages/infinite-canvas/toolbar/CanvasFilterBar.tsx
Normal file
89
src/pages/infinite-canvas/toolbar/CanvasFilterBar.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1 }}>
|
||||
{/* 关键词搜索 */}
|
||||
<Input
|
||||
placeholder="搜索文件名/描述…"
|
||||
prefix={<SearchOutlined />}
|
||||
value={keywordQuery}
|
||||
onChange={handleKeywordChange}
|
||||
allowClear
|
||||
size="small"
|
||||
style={{ width: 180, ...inputStyle }}
|
||||
/>
|
||||
|
||||
{/* 审核状态筛选(仅可审核阶段) */}
|
||||
{!isAssets && (
|
||||
<Select
|
||||
value={auditFilter}
|
||||
onChange={(v) => setFilters({ auditFilter: v })}
|
||||
size="small"
|
||||
style={{ width: 110 }}
|
||||
options={AUDIT_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 资产类型筛选(仅 Assets 阶段) */}
|
||||
{isAssets && (
|
||||
<Select
|
||||
value={assetTypeFilter}
|
||||
onChange={(v) => setFilters({ assetTypeFilter: v })}
|
||||
size="small"
|
||||
style={{ width: 110 }}
|
||||
options={ASSET_TYPE_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/pages/infinite-canvas/toolbar/CanvasToolbar.tsx
Normal file
129
src/pages/infinite-canvas/toolbar/CanvasToolbar.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
background: barBg,
|
||||
}}
|
||||
>
|
||||
{/* 第一行:工作区 + 项目选择器 + 集数选择器 + 阶段标签 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 12px',
|
||||
gap: 8,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => void handleOpenWorkspace()}
|
||||
style={{
|
||||
color: isDark ? '#C4B5FD' : '#4338CA',
|
||||
fontWeight: 500,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{workspaceRoot ? '切换' : '打开'}
|
||||
</Button>
|
||||
|
||||
{workspaceRoot && <ProjectSelector />}
|
||||
{workspaceRoot && <EpisodeSelector />}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<StageTabs />
|
||||
|
||||
{isScanning && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#C4B5FD' : '#6366F1',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
扫描中…
|
||||
</span>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#6E6B90' : '#9CA3AF',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{totalCount} 个文件
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 第二行:筛选栏 */}
|
||||
{workspaceRoot && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px 12px 6px',
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<CanvasFilterBar />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/pages/infinite-canvas/toolbar/StageTabs.tsx
Normal file
57
src/pages/infinite-canvas/toolbar/StageTabs.tsx
Normal file
@@ -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 (
|
||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{STAGE_CONFIGS.map((cfg) => {
|
||||
const isActive = currentStage === cfg.key;
|
||||
return (
|
||||
<button
|
||||
key={cfg.key}
|
||||
type="button"
|
||||
onClick={() => setStage(cfg.key as StageType)}
|
||||
title={cfg.label}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
background: isActive
|
||||
? isDark
|
||||
? '#3E3A6A'
|
||||
: '#E0E7FF'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? isDark
|
||||
? '#C4B5FD'
|
||||
: '#4338CA'
|
||||
: isDark
|
||||
? '#6E6B90'
|
||||
: '#9CA3AF',
|
||||
transition: 'all 0.15s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 4 }}>{cfg.icon}</span>
|
||||
{cfg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/pages/infinite-canvas/toolbar/index.ts
Normal file
7
src/pages/infinite-canvas/toolbar/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ============================================================
|
||||
// toolbar — 顶部工具栏:布局 + 筛选 + 阶段切换
|
||||
// ============================================================
|
||||
|
||||
export { CanvasToolbar } from './CanvasToolbar';
|
||||
export { CanvasFilterBar } from './CanvasFilterBar';
|
||||
export { StageTabs } from './StageTabs';
|
||||
183
src/pages/infinite-canvas/utils/ICanvasDataSource.ts
Normal file
183
src/pages/infinite-canvas/utils/ICanvasDataSource.ts
Normal file
@@ -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<ScanEntry[]>;
|
||||
|
||||
/**
|
||||
* 扫描项目(推荐方式)。
|
||||
* 接收 projectPath + 筛选条件,主进程内部完成目录探索 + 文件遍历。
|
||||
* 渲染进程无需直接访问 fs 模块。
|
||||
*/
|
||||
scanProject(params: {
|
||||
projectPath: string;
|
||||
stage: string;
|
||||
assetTypeFilter: string;
|
||||
shotQuery: string;
|
||||
}): Promise<ScanEntry[]>;
|
||||
|
||||
/** 读取文件的旁加载元数据(sidecar JSON) */
|
||||
readSidecar(filePath: string): Promise<SidecarMetadata | null>;
|
||||
|
||||
/** 写入文件的旁加载元数据 */
|
||||
writeSidecar(filePath: string, metadata: SidecarMetadata): Promise<void>;
|
||||
|
||||
/** 生成缩略图(返回 base64 data URL) */
|
||||
generateThumbnail(request: ThumbnailRequest): Promise<ThumbnailResult>;
|
||||
|
||||
/** 获取文件二进制数据(base64 编码) */
|
||||
readFileData(filePath: string): Promise<string | null>;
|
||||
|
||||
/** 获取文件访问 URL(用于 <img> / <video> 的 src 属性) */
|
||||
getFileUrl(filePath: string): Promise<string>;
|
||||
|
||||
/** 检查文件/目录是否存在 */
|
||||
fileExists(filePath: string): Promise<boolean>;
|
||||
|
||||
/** 复制文件到目录(导入操作),返回目标路径 */
|
||||
copyFile(src: string, destDir: string, fileName: string): Promise<string>;
|
||||
|
||||
/** 删除文件及其 sidecar */
|
||||
deleteFile(filePath: string): Promise<void>;
|
||||
|
||||
/** 打开目录选择对话框,返回选中的路径 */
|
||||
openDirectoryDialog(): Promise<string | null>;
|
||||
|
||||
/** 列出目录下的子文件夹名称(非递归,排除隐藏目录) */
|
||||
listSubdirs(parentPath: string): Promise<string[]>;
|
||||
|
||||
/** 扫描指定镜头的阶段文件 */
|
||||
scanStageFiles(params: {
|
||||
projectPath: string;
|
||||
episode: string;
|
||||
shot: string;
|
||||
stage: string;
|
||||
}): Promise<ScanEntry[]>;
|
||||
|
||||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||
scanAssetFiles(params: {
|
||||
projectPath: string;
|
||||
assetTypeFilter: string;
|
||||
}): Promise<ScanEntry[]>;
|
||||
|
||||
/** 创建新目录,返回创建的完整路径 */
|
||||
createDirectory(parentPath: string, dirName: string): Promise<string>;
|
||||
}
|
||||
253
src/pages/infinite-canvas/utils/LocalFileDataSource.ts
Normal file
253
src/pages/infinite-canvas/utils/LocalFileDataSource.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// ============================================================
|
||||
// LocalFileDataSource — 离线模式:Electron IPC → 本地文件系统
|
||||
//
|
||||
// 所有文件系统操作通过 window.canvas(preload 注入)桥接到主进程。
|
||||
// 非 Electron 环境(浏览器开发模式)降级返回空数据/抛出友好错误。
|
||||
// ============================================================
|
||||
|
||||
import { hasIpc, safeIpcInvoke } from '@/utils/bus';
|
||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||
import type {
|
||||
ICanvasDataSource,
|
||||
ScanRequest,
|
||||
ScanEntry,
|
||||
ScanProgressCallback,
|
||||
SidecarMetadata,
|
||||
ThumbnailRequest,
|
||||
ThumbnailResult,
|
||||
DataSourceCapabilities,
|
||||
} from './ICanvasDataSource';
|
||||
|
||||
// ---------- IPC 返回类型 ----------
|
||||
|
||||
/** scanDirectory IPC 返回类型 */
|
||||
type ScanDirResult = { success: boolean; data: ScanEntry[]; error?: string };
|
||||
|
||||
/** readSidecar / writeSidecar IPC 返回类型 */
|
||||
type SidecarResult = { success: boolean; data: SidecarMetadata | null; error?: string };
|
||||
type WriteSidecarResult = { success: boolean; error?: string };
|
||||
|
||||
/** generateThumbnail IPC 返回类型 */
|
||||
type ThumbnailResultIpc = { success: boolean; data: ThumbnailResult; error?: string };
|
||||
|
||||
/** copyFile IPC 返回类型 */
|
||||
type CopyFileResult = { success: boolean; data: string; error?: string };
|
||||
|
||||
/** 通用文件操作返回类型 */
|
||||
type FileExistsResult = { success: boolean; data: boolean };
|
||||
type FileDialogResult = { canceled: boolean; filePaths: string[] };
|
||||
type FileReadResult = { success: boolean; data: string };
|
||||
|
||||
// ---------- 辅助 ----------
|
||||
|
||||
/** 检查 canvas API 是否可用 */
|
||||
function hasCanvasApi(): boolean {
|
||||
return hasIpc() && typeof window.canvas !== 'undefined';
|
||||
}
|
||||
|
||||
/** 包装 IPC 调用:非 Electron 环境抛出友好错误 */
|
||||
async function requireCanvasApi(): Promise<void> {
|
||||
if (!hasCanvasApi()) {
|
||||
throw new Error('当前环境不支持文件系统操作。请在 HeiXiu 桌面应用中打开无限画布。');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 实现 ----------
|
||||
|
||||
export class LocalFileDataSource implements ICanvasDataSource {
|
||||
readonly capabilities: DataSourceCapabilities = {
|
||||
offline: true,
|
||||
realtime: false,
|
||||
thumbnails: true,
|
||||
};
|
||||
|
||||
async scanDirectory(
|
||||
request: ScanRequest,
|
||||
_onProgress?: ScanProgressCallback,
|
||||
): Promise<ScanEntry[]> {
|
||||
await requireCanvasApi();
|
||||
const result = (await window.canvas.scanDirectory({
|
||||
roots: request.roots,
|
||||
extensions: request.extensions,
|
||||
recursive: request.recursive,
|
||||
})) as ScanDirResult;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '目录扫描失败');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async scanProject(params: {
|
||||
projectPath: string;
|
||||
stage: string;
|
||||
assetTypeFilter: string;
|
||||
shotQuery: string;
|
||||
}): Promise<ScanEntry[]> {
|
||||
await requireCanvasApi();
|
||||
console.log('[LocalFileDataSource] 📡 scanProject IPC → 主进程', params);
|
||||
const t0 = performance.now();
|
||||
const result = (await window.canvas.scanProject(params)) as ScanDirResult;
|
||||
const elapsed = (performance.now() - t0).toFixed(0);
|
||||
if (!result.success) {
|
||||
console.error(`[LocalFileDataSource] ❌ scanProject 失败 (${elapsed}ms):`, result.error);
|
||||
throw new Error(result.error || '项目扫描失败');
|
||||
}
|
||||
console.log(`[LocalFileDataSource] ✅ scanProject 返回 (${elapsed}ms): ${result.data.length} 条`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async readSidecar(filePath: string): Promise<SidecarMetadata | null> {
|
||||
await requireCanvasApi();
|
||||
const result = (await window.canvas.readSidecar({ filePath })) as SidecarResult;
|
||||
if (!result.success) return null;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async writeSidecar(filePath: string, metadata: SidecarMetadata): Promise<void> {
|
||||
await requireCanvasApi();
|
||||
const result = (await window.canvas.writeSidecar({
|
||||
filePath,
|
||||
metadata,
|
||||
})) as WriteSidecarResult;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '写入元数据失败');
|
||||
}
|
||||
}
|
||||
|
||||
async generateThumbnail(request: ThumbnailRequest): Promise<ThumbnailResult> {
|
||||
await requireCanvasApi();
|
||||
const result = (await window.canvas.generateThumbnail({
|
||||
filePath: request.filePath,
|
||||
size: request.size,
|
||||
})) as ThumbnailResultIpc;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '生成缩略图失败');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async readFileData(filePath: string): Promise<string | null> {
|
||||
await requireCanvasApi();
|
||||
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_READ, {
|
||||
relativePath: filePath,
|
||||
})) as FileReadResult | undefined;
|
||||
return result?.success ? result.data : null;
|
||||
}
|
||||
|
||||
async getFileUrl(filePath: string): Promise<string> {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
|
||||
if (imageExts.includes(ext)) {
|
||||
try {
|
||||
const thumb = await this.generateThumbnail({ filePath, size: 1024 });
|
||||
return `data:${thumb.mimeType};base64,${thumb.data}`;
|
||||
} catch {
|
||||
return `file://${filePath}`;
|
||||
}
|
||||
}
|
||||
return `file://${filePath}`;
|
||||
}
|
||||
|
||||
async fileExists(filePath: string): Promise<boolean> {
|
||||
await requireCanvasApi();
|
||||
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_EXISTS, {
|
||||
relativePath: filePath,
|
||||
})) as FileExistsResult | undefined;
|
||||
return result?.success ? result.data : false;
|
||||
}
|
||||
|
||||
async copyFile(src: string, destDir: string, fileName: string): Promise<string> {
|
||||
await requireCanvasApi();
|
||||
const result = (await window.canvas.copyFile({
|
||||
src,
|
||||
destDir,
|
||||
fileName,
|
||||
})) as CopyFileResult;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '复制文件失败');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
await requireCanvasApi();
|
||||
await safeIpcInvoke(BIDIRECTIONAL.FILE_DELETE, { relativePath: filePath });
|
||||
const sidecarPath = filePath.replace(/\.[^.]+$/, '.json');
|
||||
await safeIpcInvoke(BIDIRECTIONAL.FILE_DELETE, { relativePath: sidecarPath });
|
||||
}
|
||||
|
||||
async openDirectoryDialog(): Promise<string | null> {
|
||||
await requireCanvasApi();
|
||||
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_DIALOG, {
|
||||
title: '选择工作区根目录',
|
||||
properties: ['openDirectory'],
|
||||
})) as FileDialogResult | undefined;
|
||||
if (!result || result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
}
|
||||
|
||||
async listSubdirs(parentPath: string): Promise<string[]> {
|
||||
await requireCanvasApi();
|
||||
const result = (await window.canvas.listSubdirs({ parentPath })) as {
|
||||
success: boolean;
|
||||
data: string[];
|
||||
error?: string;
|
||||
};
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '列出子目录失败');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async scanStageFiles(params: {
|
||||
projectPath: string;
|
||||
episode: string;
|
||||
shot: string;
|
||||
stage: string;
|
||||
}): Promise<ScanEntry[]> {
|
||||
await requireCanvasApi();
|
||||
console.log('[LocalFileDataSource] 📡 scanStageFiles IPC → 主进程', params);
|
||||
const t0 = performance.now();
|
||||
const result = (await window.canvas.scanStageFiles(params)) as ScanDirResult;
|
||||
const elapsed = (performance.now() - t0).toFixed(0);
|
||||
if (!result.success) {
|
||||
console.error(`[LocalFileDataSource] ❌ scanStageFiles 失败 (${elapsed}ms):`, result.error);
|
||||
throw new Error(result.error || '扫描阶段文件失败');
|
||||
}
|
||||
console.log(`[LocalFileDataSource] ✅ scanStageFiles 返回 (${elapsed}ms): ${result.data.length} 条`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async scanAssetFiles(params: {
|
||||
projectPath: string;
|
||||
assetTypeFilter: string;
|
||||
}): Promise<ScanEntry[]> {
|
||||
await requireCanvasApi();
|
||||
console.log('[LocalFileDataSource] 📡 scanAssetFiles IPC → 主进程', params);
|
||||
const t0 = performance.now();
|
||||
const result = (await window.canvas.scanAssetFiles(params)) as ScanDirResult;
|
||||
const elapsed = (performance.now() - t0).toFixed(0);
|
||||
if (!result.success) {
|
||||
console.error(`[LocalFileDataSource] ❌ scanAssetFiles 失败 (${elapsed}ms):`, result.error);
|
||||
throw new Error(result.error || '扫描资产文件失败');
|
||||
}
|
||||
console.log(`[LocalFileDataSource] ✅ scanAssetFiles 返回 (${elapsed}ms): ${result.data.length} 条`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async createDirectory(parentPath: string, dirName: string): Promise<string> {
|
||||
await requireCanvasApi();
|
||||
console.log('[LocalFileDataSource] 📡 createDirectory IPC → 主进程', { parentPath, dirName });
|
||||
const result = (await window.canvas.createDirectory({ parentPath, dirName })) as {
|
||||
success: boolean;
|
||||
data: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!result.success) {
|
||||
console.error('[LocalFileDataSource] ❌ createDirectory 失败:', result.error);
|
||||
throw new Error(result.error || '创建目录失败');
|
||||
}
|
||||
console.log('[LocalFileDataSource] ✅ createDirectory 成功:', result.data);
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
70
src/pages/infinite-canvas/utils/constants.ts
Normal file
70
src/pages/infinite-canvas/utils/constants.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// ============================================================
|
||||
// constants.ts — 无限画布常量配置
|
||||
// ============================================================
|
||||
|
||||
import type { StageType } from '../store/useCanvasStore';
|
||||
|
||||
// ---------- 布局 ----------
|
||||
|
||||
/** 网格模式:默认列数 */
|
||||
export const DEFAULT_COLS = 9;
|
||||
/** 网格模式:每页行数 */
|
||||
export const DEFAULT_ROWS_PER_PAGE = 16;
|
||||
/** 单行模式:每页镜头数 */
|
||||
export const DEFAULT_SINGLE_ROWS = 16;
|
||||
|
||||
/** 缩略图卡片尺寸(像素) */
|
||||
export const ITEM_W = 160;
|
||||
export const ITEM_H = 120;
|
||||
export const ITEM_SPACING = 8;
|
||||
|
||||
/** 行标签宽度(single 模式) */
|
||||
export const ROW_LABEL_WIDTH = 80;
|
||||
|
||||
/** 单页最大渲染数量(硬上限) */
|
||||
export const MAX_ITEMS_PER_PAGE = 500;
|
||||
|
||||
// ---------- LOD 缩略图 ----------
|
||||
|
||||
/** LOD 级别 → 像素尺寸 */
|
||||
export const LOD_LEVELS = [512, 1024, 2048, 4096] as const;
|
||||
export type LODLevel = (typeof LOD_LEVELS)[number];
|
||||
|
||||
/** 最大并发缩略图加载数 */
|
||||
export const MAX_CONCURRENT_LOADS = 10;
|
||||
|
||||
// ---------- 阶段配置 ----------
|
||||
|
||||
export interface StageConfig {
|
||||
key: StageType;
|
||||
label: string;
|
||||
icon: string;
|
||||
auditEnabled: boolean;
|
||||
}
|
||||
|
||||
/** 六个阶段的静态配置 */
|
||||
export const STAGE_CONFIGS: StageConfig[] = [
|
||||
{ key: 'Assets', label: '资产', icon: '📦', auditEnabled: false },
|
||||
{ key: 'Storyboard', label: '分镜', icon: '🎬', auditEnabled: true },
|
||||
{ key: 'Keyframe', label: '关键帧', icon: '🖼️', auditEnabled: true },
|
||||
{ key: 'Video', label: '视频', icon: '🎥', auditEnabled: true },
|
||||
{ key: 'Audio', label: '音频', icon: '🎵', auditEnabled: false },
|
||||
{ key: 'LipSync', label: '口型', icon: '👄', auditEnabled: false },
|
||||
];
|
||||
|
||||
// ---------- 审核状态 ----------
|
||||
|
||||
export const AUDIT_STATUS_LABELS: Record<string, string> = {
|
||||
all: '全部',
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rework: '需返工',
|
||||
deprecated: '已废弃',
|
||||
};
|
||||
|
||||
export const AUDIT_STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#FAAD14',
|
||||
approved: '#52C41A',
|
||||
rework: '#FF4D4F',
|
||||
deprecated: '#8C8C8C',
|
||||
};
|
||||
30
src/pages/infinite-canvas/utils/index.ts
Normal file
30
src/pages/infinite-canvas/utils/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// ============================================================
|
||||
// utils — 无限画布工具函数、常量与数据源
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
gridPosition,
|
||||
gridPageInfo,
|
||||
gridCanvasSize,
|
||||
singleLayout,
|
||||
singlePageInfo,
|
||||
} from './layout';
|
||||
export {
|
||||
STAGE_CONFIGS,
|
||||
ITEM_W,
|
||||
ITEM_H,
|
||||
ITEM_SPACING,
|
||||
ROW_LABEL_WIDTH,
|
||||
MAX_ITEMS_PER_PAGE,
|
||||
LOD_LEVELS,
|
||||
AUDIT_STATUS_COLORS,
|
||||
} from './constants';
|
||||
export type { LODLevel } from './constants';
|
||||
export type {
|
||||
ICanvasDataSource,
|
||||
ScanRequest,
|
||||
ScanEntry,
|
||||
ScanProgressCallback,
|
||||
SidecarMetadata,
|
||||
} from './ICanvasDataSource';
|
||||
export { LocalFileDataSource } from './LocalFileDataSource';
|
||||
170
src/pages/infinite-canvas/utils/layout.ts
Normal file
170
src/pages/infinite-canvas/utils/layout.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// ============================================================
|
||||
// layout.ts — 画布布局算法
|
||||
//
|
||||
// 实现 Grid 模式和 Single 模式的坐标计算。
|
||||
// ============================================================
|
||||
|
||||
import type { CatalogItem } from '../store/useCanvasStore';
|
||||
import {
|
||||
ITEM_W,
|
||||
ITEM_H,
|
||||
ITEM_SPACING,
|
||||
ROW_LABEL_WIDTH,
|
||||
MAX_ITEMS_PER_PAGE,
|
||||
} from './constants';
|
||||
|
||||
// ---------- 通用类型 ----------
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
/** 总页数 */
|
||||
totalPages: number;
|
||||
/** 当前页的 item 索引范围 [start, end) */
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
// ---------- Grid 模式 ----------
|
||||
|
||||
/**
|
||||
* Grid 模式:计算 item 的绝对像素坐标。
|
||||
*
|
||||
* @param index item 在当前页 items 数组中的局部索引(0-based)
|
||||
* @param cols 每行列数
|
||||
*/
|
||||
export function gridPosition(index: number, cols: number): Position {
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
return {
|
||||
x: col * (ITEM_W + ITEM_SPACING),
|
||||
y: row * (ITEM_H + ITEM_SPACING),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid 模式:计算分页信息。
|
||||
*
|
||||
* @param totalItems 全量过滤后的 item 总数
|
||||
* @param cols 每行列数
|
||||
* @param rowsPerPage 每页行数
|
||||
* @param currentPage 当前页码(1-based)
|
||||
*/
|
||||
export function gridPageInfo(
|
||||
totalItems: number,
|
||||
cols: number,
|
||||
rowsPerPage: number,
|
||||
currentPage: number,
|
||||
): PageInfo {
|
||||
const perPage = Math.min(cols * rowsPerPage, MAX_ITEMS_PER_PAGE);
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
||||
const page = Math.max(1, Math.min(currentPage, totalPages));
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = Math.min(startIndex + perPage, totalItems);
|
||||
return { totalPages, startIndex, endIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid 模式:计算画布总尺寸(像素)。
|
||||
*/
|
||||
export function gridCanvasSize(
|
||||
itemsOnPage: number,
|
||||
cols: number,
|
||||
): { width: number; height: number } {
|
||||
const rows = Math.ceil(itemsOnPage / cols);
|
||||
const width = cols * (ITEM_W + ITEM_SPACING) - ITEM_SPACING;
|
||||
const height = rows * (ITEM_H + ITEM_SPACING) - ITEM_SPACING;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// ---------- Single 模式 ----------
|
||||
|
||||
/**
|
||||
* Single 模式:按 groupKey 分组,每组一行横向排列。
|
||||
* 返回每个 item 的位置和画布尺寸。
|
||||
*
|
||||
* @param itemsOnPage 当前页的 item 列表
|
||||
*/
|
||||
export function singleLayout(
|
||||
itemsOnPage: CatalogItem[],
|
||||
): { positions: Map<number, Position>; canvasWidth: number; canvasHeight: number } {
|
||||
// 按 groupKey 分组(保持插入顺序)
|
||||
const groups = new Map<string, CatalogItem[]>();
|
||||
for (const item of itemsOnPage) {
|
||||
const key = item.groupKey || 'unknown';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(item);
|
||||
}
|
||||
|
||||
const positions = new Map<number, Position>();
|
||||
let maxWidth = 0;
|
||||
let row = 0;
|
||||
|
||||
for (const [, groupItems] of groups) {
|
||||
let col = 0;
|
||||
for (const item of groupItems) {
|
||||
positions.set(item.id, {
|
||||
x: ROW_LABEL_WIDTH + ITEM_SPACING + col * (ITEM_W + ITEM_SPACING),
|
||||
y: row * (ITEM_H + ITEM_SPACING),
|
||||
});
|
||||
col++;
|
||||
}
|
||||
const rowWidth =
|
||||
ROW_LABEL_WIDTH + ITEM_SPACING + col * (ITEM_W + ITEM_SPACING);
|
||||
if (rowWidth > maxWidth) maxWidth = rowWidth;
|
||||
row++;
|
||||
}
|
||||
|
||||
const canvasHeight = row * (ITEM_H + ITEM_SPACING) - ITEM_SPACING;
|
||||
return { positions, canvasWidth: maxWidth, canvasHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single 模式:计算分页信息。
|
||||
* 每页最多 singleRowsPerPage 个镜头组。
|
||||
*/
|
||||
export function singlePageInfo(
|
||||
items: CatalogItem[],
|
||||
singleRowsPerPage: number,
|
||||
currentPage: number,
|
||||
): PageInfo {
|
||||
// 收集所有 groupKey(保持顺序、去重)
|
||||
const groupKeys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
const key = item.groupKey || 'unknown';
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
groupKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(groupKeys.length / singleRowsPerPage));
|
||||
const page = Math.max(1, Math.min(currentPage, totalPages));
|
||||
const startGroupIdx = (page - 1) * singleRowsPerPage;
|
||||
const endGroupIdx = Math.min(startGroupIdx + singleRowsPerPage, groupKeys.length);
|
||||
|
||||
// 将 group key 范围转换为 item 索引范围
|
||||
let startIndex = 0;
|
||||
let endIndex = items.length;
|
||||
let groupIdx = 0;
|
||||
let currentGroup = '';
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const key = items[i].groupKey || 'unknown';
|
||||
if (key !== currentGroup) {
|
||||
if (groupIdx === startGroupIdx) startIndex = i;
|
||||
if (groupIdx === endGroupIdx) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
currentGroup = key;
|
||||
groupIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalPages, startIndex, endIndex };
|
||||
}
|
||||
252
src/pages/infinite-canvas/utils/scanner.ts
Normal file
252
src/pages/infinite-canvas/utils/scanner.ts
Normal 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/ 目录 → 下一层为 type(chr/prp/set)
|
||||
* → 再下一层为 name(角色名/道具名/场景名)
|
||||
* → 返回 "chr/主角A"
|
||||
*
|
||||
* 非 Assets 阶段:
|
||||
* 向上找到 shots/ 目录 → 下一层为 episode(E01)
|
||||
* → 再下一层为 shot(SC001)
|
||||
* → 返回 "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;
|
||||
}
|
||||
}
|
||||
22
src/vite-env.d.ts
vendored
22
src/vite-env.d.ts
vendored
@@ -28,6 +28,28 @@ declare global {
|
||||
appRuntime: {
|
||||
setWindowEdition(edition: string | null): void;
|
||||
};
|
||||
/** 无限画布 API(由 preload 注入) */
|
||||
canvas: {
|
||||
openWindow(projectPath?: string): void;
|
||||
scanDirectory(params: { roots: string[]; extensions: string[]; recursive: boolean }): Promise<unknown>;
|
||||
scanProject(params: { projectPath: string; stage: string; assetTypeFilter: string; shotQuery: string }): Promise<unknown>;
|
||||
readSidecar(params: { filePath: string }): Promise<unknown>;
|
||||
writeSidecar(params: { filePath: string; metadata: object }): Promise<unknown>;
|
||||
generateThumbnail(params: { filePath: string; size: number }): Promise<unknown>;
|
||||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<unknown>;
|
||||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||||
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 }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user