lovart3/store/canvas/index.ts
Your Name 47afddfd73 feat: 添加物品拾取功能(MVP版本)
- 新增 ObjectPickerOverlay 组件,支持点击图片拾取物品
- 新增 /api/pick-object API,调用 Qwen-VL 识别物品
- Canvas Store 扩展拾取模式状态管理
- ImageNode 工具栏新增拾取按钮
- CanvasChatPanel 集成物品标签,发送时自动构建精准prompt
- 图片压缩至 600px 以内加速 AI 分析
- 物品名称限制 8 字,位置固定 4 字标准词库
2026-02-02 11:50:30 +08:00

503 lines
15 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.

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,
};
// 待添加到对话框的图片附件
interface PendingChatAttachment {
url: string;
name: string;
contentType: string;
}
// 🆕 拾取的物品信息
export interface PickedObject {
// 原图信息
imageUrl: string;
imageNodeId: string;
// 点击位置(相对坐标 0-1)
clickX: number;
clickY: number;
// AI 识别结果
objectName: string; // ≤8字,格式:[颜色/材质]+[物品]
objectPosition: string; // 固定4字,如"左上区域"
confidence: number; // 置信度 0-1
// 物品边界框
boundingBox: {
x: number;
y: number;
width: number;
height: number;
};
}
// 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;
// 🆕 待添加到对话框的图片(用于从画布添加图片到对话)
pendingChatAttachments: PendingChatAttachment[];
// 🆕 物品拾取模式
isPickerMode: boolean;
pickerTargetNodeId: string | null;
isPickerLoading: boolean;
pickedObject: PickedObject | null;
pendingPickedObjects: PickedObject[];
// 项目操作
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[];
// 🆕 对话框附件操作
addPendingChatAttachment: (attachment: PendingChatAttachment) => void;
clearPendingChatAttachments: () => void;
// 🆕 物品拾取操作
setPickerMode: (enabled: boolean, nodeId?: string) => void;
setPickerLoading: (loading: boolean) => void;
setPickedObject: (obj: PickedObject | null) => void;
addPendingPickedObject: (obj: PickedObject) => void;
clearPendingPickedObjects: () => void;
}
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,
pendingChatAttachments: [], // 🆕 待添加到对话框的图片
// 🆕 物品拾取模式初始状态
isPickerMode: false,
pickerTargetNodeId: null,
isPickerLoading: false,
pickedObject: null,
pendingPickedObjects: [],
// 项目操作
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),
// 🆕 对话框附件操作
addPendingChatAttachment: (attachment) => set((state) => {
// 避免重复添加
if (!state.pendingChatAttachments.some(a => a.url === attachment.url)) {
state.pendingChatAttachments.push(attachment);
}
}),
clearPendingChatAttachments: () => set((state) => {
state.pendingChatAttachments = [];
}),
// 🆕 物品拾取操作
setPickerMode: (enabled, nodeId) => set((state) => {
state.isPickerMode = enabled;
state.pickerTargetNodeId = enabled ? (nodeId || null) : null;
if (!enabled) {
state.pickedObject = null;
state.isPickerLoading = false;
}
}),
setPickerLoading: (loading) => set((state) => {
state.isPickerLoading = loading;
}),
setPickedObject: (obj) => set((state) => {
state.pickedObject = obj;
}),
addPendingPickedObject: (obj) => set((state) => {
// 避免重复添加
if (!state.pendingPickedObjects.some(o =>
o.imageUrl === obj.imageUrl &&
o.clickX === obj.clickX &&
o.clickY === obj.clickY
)) {
state.pendingPickedObjects.push(obj);
}
}),
clearPendingPickedObjects: () => set((state) => {
state.pendingPickedObjects = [];
}),
})),
{
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,
};