// ============================================================ // 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; // ── Stage & Filters ── currentStage: StageType; keywordQuery: string; tagQuery: string; auditFilter: AuditStatus | 'all'; assetTypeFilter: AssetTypeFilter; /** 仅显示收藏条目(metadata.favorite === true) */ favoriteOnly: boolean; // ── 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' | 'favoriteOnly' > >, ) => 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) => void; loadThumbnail: (id: number, blobUrl: string, level: number) => void; /** 获取 dataSource(供 hooks 使用) */ getDataSource: () => ICanvasDataSource; } // ---------- Store 逻辑工厂 ---------- function createCanvasStoreLogic(dataSource: ICanvasDataSource, initialWorkspace: string | null) { return createStore()((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: '全部', favoriteOnly: false, 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), 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; /** 模块级 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(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(selector: (state: CanvasState) => T): T { const store = useContext(CanvasStoreContext); if (!store) { throw new Error('useCanvasStore 必须在 CanvasStoreProvider 内部使用'); } return useStore(store, selector); }