画布交互: - 滚轮缩放视口(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)
226 lines
7.1 KiB
TypeScript
226 lines
7.1 KiB
TypeScript
// ============================================================
|
||
// 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>
|
||
);
|
||
}
|