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

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

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

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

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

View File

@@ -0,0 +1,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>
);
}