Files
ele-HeiXiu/src/pages/infinite-canvas/CanvasPage.tsx
YoungestSongMo 4ea414c9c7 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)
2026-06-23 20:11:50 +08:00

226 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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>
);
}