- Add canvas project and node database tables - Create canvas workspace with drag, resize, zoom and pan - Add ImageNode with background removal, enhance, extend, angle edit, scene fusion - Add VideoNode with playback controls - Add TextNode for text editing - Integrate AI image generation API (Gemini/Alibaba) - Implement viewport culling for performance optimization - Add canvas entry in top navigation bar
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { create } from 'zustand';
|
|
import { immer } from 'zustand/middleware/immer';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import type {
|
|
CanvasNode,
|
|
CanvasEdge,
|
|
CanvasState,
|
|
Viewport,
|
|
ToolType,
|
|
Position,
|
|
Size,
|
|
NodeType,
|
|
} from '@/types/canvas';
|
|
|
|
// 生成唯一 ID
|
|
const generateId = () => crypto.randomUUID();
|
|
|
|
// 默认视口
|
|
const defaultViewport: Viewport = {
|
|
x: 0,
|
|
y: 0,
|
|
zoom: 1,
|
|
};
|
|
|
|
// Store 状态接口
|
|
interface CanvasStore {
|
|
// 项目信息
|
|
projectId: string | null;
|
|
projectTitle: string;
|
|
|
|
// 画布状态
|
|
nodes: CanvasNode[];
|
|
edges: CanvasEdge[];
|
|
selectedNodeIds: string[];
|
|
viewport: Viewport;
|
|
tool: ToolType;
|
|
|
|
// 历史记录(用于撤销/重做)
|
|
history: CanvasState[];
|
|
historyIndex: number;
|
|
|
|
// UI 状态
|
|
isPanelOpen: boolean;
|
|
isGenerating: boolean;
|
|
|
|
// 项目操作
|
|
setProject: (id: string | null, title?: string) => void;
|
|
setProjectTitle: (title: string) => void;
|
|
|
|
// 节点操作
|
|
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
|
|
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
|
deleteNode: (id: string) => void;
|
|
deleteSelectedNodes: () => void;
|
|
|
|
// 选择操作
|
|
selectNode: (id: string, multi?: boolean) => void;
|
|
selectNodes: (ids: string[]) => void;
|
|
clearSelection: () => void;
|
|
selectAll: () => void;
|
|
|
|
// 边操作
|
|
addEdge: (edge: Omit<CanvasEdge, 'id'>) => string;
|
|
deleteEdge: (id: string) => void;
|
|
|
|
// 视口操作
|
|
setViewport: (viewport: Partial<Viewport>) => void;
|
|
zoomIn: () => void;
|
|
zoomOut: () => void;
|
|
zoomTo: (zoom: number) => void;
|
|
fitToScreen: () => void;
|
|
centerNode: (nodeId: string) => void;
|
|
|
|
// 工具操作
|
|
setTool: (tool: ToolType) => void;
|
|
|
|
// 历史操作
|
|
undo: () => void;
|
|
redo: () => void;
|
|
pushHistory: () => void;
|
|
|
|
// UI 操作
|
|
togglePanel: () => void;
|
|
setGenerating: (isGenerating: boolean) => void;
|
|
|
|
// 批量操作
|
|
loadCanvas: (state: Partial<CanvasState>) => void;
|
|
clearCanvas: () => void;
|
|
getCanvasState: () => CanvasState;
|
|
|
|
// 节点位置操作
|
|
moveNode: (id: string, position: Position) => void;
|
|
resizeNode: (id: string, size: Size) => void;
|
|
|
|
// 辅助方法
|
|
getNode: (id: string) => CanvasNode | undefined;
|
|
getSelectedNodes: () => CanvasNode[];
|
|
getNodesByType: (type: NodeType) => CanvasNode[];
|
|
}
|
|
|
|
export const useCanvasStore = create<CanvasStore>()(
|
|
devtools(
|
|
persist(
|
|
immer((set, get) => ({
|
|
// 初始状态
|
|
projectId: null,
|
|
projectTitle: '未命名画布',
|
|
nodes: [],
|
|
edges: [],
|
|
selectedNodeIds: [],
|
|
viewport: defaultViewport,
|
|
tool: 'select',
|
|
history: [],
|
|
historyIndex: -1,
|
|
isPanelOpen: true,
|
|
isGenerating: false,
|
|
|
|
// 项目操作
|
|
setProject: (id, title) => set((state) => {
|
|
state.projectId = id;
|
|
if (title) state.projectTitle = title;
|
|
}),
|
|
|
|
setProjectTitle: (title) => set((state) => {
|
|
state.projectTitle = title;
|
|
}),
|
|
|
|
// 节点操作
|
|
addNode: (nodeData) => {
|
|
const id = generateId();
|
|
const now = new Date();
|
|
const node: CanvasNode = {
|
|
...nodeData,
|
|
id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
set((state) => {
|
|
state.nodes.push(node);
|
|
});
|
|
|
|
// 添加到历史
|
|
get().pushHistory();
|
|
|
|
return id;
|
|
},
|
|
|
|
updateNode: (id, updates) => set((state) => {
|
|
const index = state.nodes.findIndex(n => n.id === id);
|
|
if (index !== -1) {
|
|
state.nodes[index] = {
|
|
...state.nodes[index],
|
|
...updates,
|
|
updatedAt: new Date(),
|
|
};
|
|
}
|
|
}),
|
|
|
|
deleteNode: (id) => {
|
|
set((state) => {
|
|
state.nodes = state.nodes.filter(n => n.id !== id);
|
|
state.edges = state.edges.filter(e => e.source !== id && e.target !== id);
|
|
state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id);
|
|
});
|
|
get().pushHistory();
|
|
},
|
|
|
|
deleteSelectedNodes: () => {
|
|
const { selectedNodeIds } = get();
|
|
set((state) => {
|
|
state.nodes = state.nodes.filter(n => !selectedNodeIds.includes(n.id));
|
|
state.edges = state.edges.filter(
|
|
e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target)
|
|
);
|
|
state.selectedNodeIds = [];
|
|
});
|
|
get().pushHistory();
|
|
},
|
|
|
|
// 选择操作
|
|
selectNode: (id, multi = false) => set((state) => {
|
|
if (multi) {
|
|
const index = state.selectedNodeIds.indexOf(id);
|
|
if (index === -1) {
|
|
state.selectedNodeIds.push(id);
|
|
} else {
|
|
state.selectedNodeIds.splice(index, 1);
|
|
}
|
|
} else {
|
|
state.selectedNodeIds = [id];
|
|
}
|
|
}),
|
|
|
|
selectNodes: (ids) => set((state) => {
|
|
state.selectedNodeIds = ids;
|
|
}),
|
|
|
|
clearSelection: () => set((state) => {
|
|
state.selectedNodeIds = [];
|
|
}),
|
|
|
|
selectAll: () => set((state) => {
|
|
state.selectedNodeIds = state.nodes.map(n => n.id);
|
|
}),
|
|
|
|
// 边操作
|
|
addEdge: (edgeData) => {
|
|
const id = generateId();
|
|
const edge: CanvasEdge = { ...edgeData, id };
|
|
|
|
set((state) => {
|
|
// 检查是否已存在相同的边
|
|
const exists = state.edges.some(
|
|
e => e.source === edge.source && e.target === edge.target
|
|
);
|
|
if (!exists) {
|
|
state.edges.push(edge);
|
|
}
|
|
});
|
|
|
|
return id;
|
|
},
|
|
|
|
deleteEdge: (id) => set((state) => {
|
|
state.edges = state.edges.filter(e => e.id !== id);
|
|
}),
|
|
|
|
// 视口操作
|
|
setViewport: (viewport) => set((state) => {
|
|
state.viewport = { ...state.viewport, ...viewport };
|
|
}),
|
|
|
|
zoomIn: () => set((state) => {
|
|
state.viewport.zoom = Math.min(state.viewport.zoom * 1.2, 4);
|
|
}),
|
|
|
|
zoomOut: () => set((state) => {
|
|
state.viewport.zoom = Math.max(state.viewport.zoom / 1.2, 0.1);
|
|
}),
|
|
|
|
zoomTo: (zoom) => set((state) => {
|
|
state.viewport.zoom = Math.max(0.1, Math.min(zoom, 4));
|
|
}),
|
|
|
|
fitToScreen: () => set((state) => {
|
|
// TODO: 计算所有节点的边界并调整视口
|
|
state.viewport = { x: 0, y: 0, zoom: 1 };
|
|
}),
|
|
|
|
centerNode: (nodeId) => {
|
|
const node = get().getNode(nodeId);
|
|
if (node) {
|
|
set((state) => {
|
|
state.viewport.x = -node.position.x - node.size.width / 2;
|
|
state.viewport.y = -node.position.y - node.size.height / 2;
|
|
});
|
|
}
|
|
},
|
|
|
|
// 工具操作
|
|
setTool: (tool) => set((state) => {
|
|
state.tool = tool;
|
|
}),
|
|
|
|
// 历史操作
|
|
undo: () => set((state) => {
|
|
if (state.historyIndex > 0) {
|
|
state.historyIndex -= 1;
|
|
const prevState = state.history[state.historyIndex];
|
|
state.nodes = prevState.nodes;
|
|
state.edges = prevState.edges;
|
|
state.selectedNodeIds = prevState.selectedNodeIds;
|
|
}
|
|
}),
|
|
|
|
redo: () => set((state) => {
|
|
if (state.historyIndex < state.history.length - 1) {
|
|
state.historyIndex += 1;
|
|
const nextState = state.history[state.historyIndex];
|
|
state.nodes = nextState.nodes;
|
|
state.edges = nextState.edges;
|
|
state.selectedNodeIds = nextState.selectedNodeIds;
|
|
}
|
|
}),
|
|
|
|
pushHistory: () => set((state) => {
|
|
const currentState: CanvasState = {
|
|
nodes: JSON.parse(JSON.stringify(state.nodes)),
|
|
edges: JSON.parse(JSON.stringify(state.edges)),
|
|
selectedNodeIds: [...state.selectedNodeIds],
|
|
viewport: { ...state.viewport },
|
|
tool: state.tool,
|
|
};
|
|
|
|
// 移除当前位置之后的历史
|
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
state.history.push(currentState);
|
|
state.historyIndex = state.history.length - 1;
|
|
|
|
// 限制历史记录数量
|
|
if (state.history.length > 50) {
|
|
state.history = state.history.slice(-50);
|
|
state.historyIndex = state.history.length - 1;
|
|
}
|
|
}),
|
|
|
|
// UI 操作
|
|
togglePanel: () => set((state) => {
|
|
state.isPanelOpen = !state.isPanelOpen;
|
|
}),
|
|
|
|
setGenerating: (isGenerating) => set((state) => {
|
|
state.isGenerating = isGenerating;
|
|
}),
|
|
|
|
// 批量操作
|
|
loadCanvas: (canvasState) => set((state) => {
|
|
if (canvasState.nodes) state.nodes = canvasState.nodes;
|
|
if (canvasState.edges) state.edges = canvasState.edges;
|
|
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
|
|
if (canvasState.viewport) state.viewport = canvasState.viewport;
|
|
if (canvasState.tool) state.tool = canvasState.tool;
|
|
}),
|
|
|
|
clearCanvas: () => set((state) => {
|
|
state.nodes = [];
|
|
state.edges = [];
|
|
state.selectedNodeIds = [];
|
|
state.viewport = defaultViewport;
|
|
state.history = [];
|
|
state.historyIndex = -1;
|
|
}),
|
|
|
|
getCanvasState: () => {
|
|
const state = get();
|
|
return {
|
|
nodes: state.nodes,
|
|
edges: state.edges,
|
|
selectedNodeIds: state.selectedNodeIds,
|
|
viewport: state.viewport,
|
|
tool: state.tool,
|
|
};
|
|
},
|
|
|
|
// 节点位置操作
|
|
moveNode: (id, position) => set((state) => {
|
|
const node = state.nodes.find(n => n.id === id);
|
|
if (node) {
|
|
node.position = position;
|
|
node.updatedAt = new Date();
|
|
}
|
|
}),
|
|
|
|
resizeNode: (id, size) => set((state) => {
|
|
const node = state.nodes.find(n => n.id === id);
|
|
if (node) {
|
|
node.size = size;
|
|
node.updatedAt = new Date();
|
|
}
|
|
}),
|
|
|
|
// 辅助方法
|
|
getNode: (id) => get().nodes.find(n => n.id === id),
|
|
|
|
getSelectedNodes: () => {
|
|
const { nodes, selectedNodeIds } = get();
|
|
return nodes.filter(n => selectedNodeIds.includes(n.id));
|
|
},
|
|
|
|
getNodesByType: (type) => get().nodes.filter(n => n.type === type),
|
|
})),
|
|
{
|
|
name: 'canvas-storage',
|
|
partialize: (state) => ({
|
|
// 只持久化部分状态
|
|
projectId: state.projectId,
|
|
projectTitle: state.projectTitle,
|
|
}),
|
|
}
|
|
),
|
|
{ name: 'canvas-store' }
|
|
)
|
|
);
|
|
|
|
// 导出选择器
|
|
export const canvasSelectors = {
|
|
nodes: (state: CanvasStore) => state.nodes,
|
|
edges: (state: CanvasStore) => state.edges,
|
|
selectedNodeIds: (state: CanvasStore) => state.selectedNodeIds,
|
|
viewport: (state: CanvasStore) => state.viewport,
|
|
tool: (state: CanvasStore) => state.tool,
|
|
isGenerating: (state: CanvasStore) => state.isGenerating,
|
|
hasSelection: (state: CanvasStore) => state.selectedNodeIds.length > 0,
|
|
canUndo: (state: CanvasStore) => state.historyIndex > 0,
|
|
canRedo: (state: CanvasStore) => state.historyIndex < state.history.length - 1,
|
|
};
|