feat: support multi-select add-to-canvas for3D world/model history
- Extend canvas store addNode's auto-overlap avoidance (ring-scan) to all node types, not just 'image'. Previously, repeated import-to-canvas for world3d/model3d nodes stacked them on the same viewport center coordinate, making them appear locked and impossible to drag apart. - Add addNodesBulk helper that loops addNode and reuses its avoidance logic, suitable for batch-importing multiple history records. - Wire selectedAddTaskIds state, addableTasks/selectedAddTasks memos, toggle/selectAll handlers, and prepare handleBatchImportToCanvas in the3D canvas page so future UI can hand off bulk import to the store.
This commit is contained in:
parent
c825618398
commit
3ca5859e26
@ -42,7 +42,7 @@ import {
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@ -339,6 +339,36 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskImportContext(task?: TaskRecord | null) {
|
||||
const isWorldTask = task?.provider === "worldlabs-3d";
|
||||
const modelUrl = resolveTaskModelUrl(task);
|
||||
const worldAssetPayload = isWorldTask ? task?.assetPayload ?? null : null;
|
||||
const primarySplatUrl =
|
||||
isWorldTask && worldAssetPayload
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
task?.previewImageUrl ||
|
||||
null;
|
||||
const canImportWorld = Boolean(
|
||||
modelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
|
||||
return {
|
||||
canImport: Boolean(task?.status === "success") && (isWorldTask ? canImportWorld : Boolean(modelUrl)),
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `slot_${index + 1}`,
|
||||
@ -642,15 +672,28 @@ function CostPill({
|
||||
|
||||
export default function CanvasModel3DPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const routeParams = useParams();
|
||||
const id = String(routeParams?.id || "");
|
||||
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
|
||||
const returnTo = useMemo(() => {
|
||||
const raw = searchParams.get("returnTo")?.trim();
|
||||
return raw || `/canvas/${id}`;
|
||||
}, [id, searchParams]);
|
||||
const shouldReturnToVideoCanvas = useMemo(
|
||||
() =>
|
||||
searchParams.get("source") === "video-canvas" ||
|
||||
/(?:\?|&)mode=video(?:&|$)|(?:\?|&)workspaceType=video(?:&|$)/.test(returnTo),
|
||||
[returnTo, searchParams]
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const addNodesBulk = useCanvasStore((state) => state.addNodesBulk);
|
||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
|
||||
const [mode, setMode] = useState<GenerationMode>("text");
|
||||
@ -724,11 +767,12 @@ export default function CanvasModel3DPage() {
|
||||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||
//多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布
|
||||
const [selectedAddTaskIds, setSelectedAddTaskIds] = useState<Set<string>>(new Set());
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>( null
|
||||
);
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
@ -1131,7 +1175,9 @@ export default function CanvasModel3DPage() {
|
||||
const pending = normalized.find(
|
||||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||||
);
|
||||
setCurrentTask(pending || normalized[0] || null);
|
||||
const initialTask = pending || normalized[0] || null;
|
||||
setCurrentTask(initialTask);
|
||||
setWorkspaceMode(inferWorkspaceModeFromTask(initialTask));
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
|
||||
const migrated = await migrateLegacyHistory();
|
||||
@ -1261,21 +1307,31 @@ export default function CanvasModel3DPage() {
|
||||
return next;
|
||||
}, [isolatedLayerId, layerVisibility, viewerLayers]);
|
||||
|
||||
const visibleTaskHistory = useMemo(
|
||||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
const shareableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||||
const visibleTaskHistory = useMemo(
|
||||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||||
[taskHistory, workspaceMode]
|
||||
);
|
||||
const shareableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||||
|
||||
// 可添加到画布的历史记录(世界 + 模型,仅成功状态)
|
||||
const addableTasks = useMemo(
|
||||
() => visibleTaskHistory.filter((task) => task.status === "success"),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedAddTasks = useMemo(
|
||||
() => addableTasks.filter((task) => selectedAddTaskIds.has(task.taskId)),
|
||||
[addableTasks, selectedAddTaskIds]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
|
||||
@ -1991,31 +2047,40 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentTask || currentTask.status !== "success") {
|
||||
const buildReturnUrl = useCallback(
|
||||
(taskId?: string) => {
|
||||
if (typeof window === "undefined") return returnTo;
|
||||
const url = new URL(returnTo, window.location.origin);
|
||||
if (taskId) {
|
||||
url.searchParams.set("directorImport", "1");
|
||||
url.searchParams.set("directorTaskId", taskId);
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
},
|
||||
[returnTo]
|
||||
);
|
||||
|
||||
const handleImportToCanvas = async (task = currentTask) => {
|
||||
if (!task || task.status !== "success") {
|
||||
toast.error("暂无可添加的 3D 资产");
|
||||
return;
|
||||
}
|
||||
|
||||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||||
const worldAssetPayload = currentTask.assetPayload ?? null;
|
||||
const primarySplatUrl = isWorldTask
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
currentTask.previewImageUrl ||
|
||||
null;
|
||||
if (shouldReturnToVideoCanvas) {
|
||||
router.push(buildReturnUrl(task.taskId));
|
||||
return;
|
||||
}
|
||||
|
||||
const canImportWorld = Boolean(
|
||||
currentModelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
const {
|
||||
canImport,
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
} = getTaskImportContext(task);
|
||||
|
||||
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
|
||||
if (!canImport) {
|
||||
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
|
||||
return;
|
||||
}
|
||||
@ -2041,14 +2106,14 @@ export default function CanvasModel3DPage() {
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
currentTask.previewImageUrl ||
|
||||
task.previewImageUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl
|
||||
? "splat"
|
||||
@ -2059,15 +2124,15 @@ export default function CanvasModel3DPage() {
|
||||
hotspots: [],
|
||||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -2077,22 +2142,22 @@ export default function CanvasModel3DPage() {
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: currentModelUrl!,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl!),
|
||||
src: modelUrl!,
|
||||
previewImage: task.previewImageUrl || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
format: extractModelFormat(modelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -2531,6 +2596,7 @@ export default function CanvasModel3DPage() {
|
||||
const handleSelectTask = (task: TaskRecord) => {
|
||||
setWorkspaceMode(inferWorkspaceModeFromTask(task));
|
||||
setCurrentTask(task);
|
||||
setSelectedWorldShareTaskIds([]);
|
||||
if (task.status === "success" && task.modelUrl) {
|
||||
setViewerLayers([]);
|
||||
setLayerVisibility({});
|
||||
@ -2615,17 +2681,7 @@ export default function CanvasModel3DPage() {
|
||||
currentWorldAsset?.thumbnailUrl ||
|
||||
(workspaceMode === "world" ? currentPreviewImage : null) ||
|
||||
null;
|
||||
const importDisabled =
|
||||
!currentTask ||
|
||||
currentTask.status !== "success" ||
|
||||
(currentTask.provider === "worldlabs-3d"
|
||||
? !Boolean(
|
||||
currentModelUrl ||
|
||||
currentWorldAsset?.worldUrl ||
|
||||
currentWorldImageUrl ||
|
||||
currentPrimarySpzUrl
|
||||
)
|
||||
: !currentModelUrl);
|
||||
const importDisabled = !getTaskImportContext(currentTask).canImport;
|
||||
const availableWorldViewportModes = useMemo(() => {
|
||||
const modes: WorldViewportMode[] = [];
|
||||
if (currentPrimarySpzUrl) modes.push("splat");
|
||||
@ -2746,7 +2802,7 @@ export default function CanvasModel3DPage() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => router.push(`/canvas/${id}`)}
|
||||
onClick={() => router.push(returnTo)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
@ -3414,7 +3470,7 @@ export default function CanvasModel3DPage() {
|
||||
<Button
|
||||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||||
disabled={importDisabled}
|
||||
onClick={handleImportToCanvas}
|
||||
onClick={() => void handleImportToCanvas(currentTask)}
|
||||
variant="secondary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||||
@ -3530,7 +3586,7 @@ export default function CanvasModel3DPage() {
|
||||
<Button
|
||||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||||
disabled={importDisabled}
|
||||
onClick={handleImportToCanvas}
|
||||
onClick={() => void handleImportToCanvas(currentTask)}
|
||||
variant="secondary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||||
|
||||
@ -122,7 +122,10 @@ interface CanvasStore {
|
||||
|
||||
// 节点操作
|
||||
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
|
||||
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
||||
addNodesBulk: (
|
||||
nodes: Array<Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>>
|
||||
) => string[];
|
||||
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
||||
deleteNode: (id: string) => void;
|
||||
deleteSelectedNodes: () => void;
|
||||
addSuppressedMediaKeys: (keys: string[]) => void;
|
||||
@ -230,82 +233,97 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
|
||||
// 节点操作
|
||||
addNode: (nodeData) => {
|
||||
const imageMetadata =
|
||||
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
|
||||
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
|
||||
: {};
|
||||
const shouldPreserveImagePosition =
|
||||
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
|
||||
const imageMetadata =
|
||||
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
|
||||
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
|
||||
: {};
|
||||
const shouldPreserveImagePosition =
|
||||
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
|
||||
|
||||
const resolveImagePosition = (
|
||||
basePosition: Position,
|
||||
size: Size,
|
||||
nodes: CanvasNode[]
|
||||
): Position => {
|
||||
if (nodeData.type !== 'image' || shouldPreserveImagePosition) return basePosition;
|
||||
const padding = 20;
|
||||
const width = size?.width || 300;
|
||||
const height = size?.height || 300;
|
||||
const stepX = width + padding;
|
||||
const stepY = height + padding;
|
||||
//通用避让算法:对所有节点类型生效(除 image 的 placementMode兼容情况外)
|
||||
// 历史原因:早期只对 image 类型做避让,导致 world3d/model3d 等重复添加会叠到一起。
|
||||
// 现在统一避让,image 的 allowOverlap/overlay 元数据仍可跳过避让以保持向后兼容。
|
||||
const resolveAutoPosition = (
|
||||
basePosition: Position,
|
||||
size: Size,
|
||||
nodes: CanvasNode[],
|
||||
nodeType: NodeType
|
||||
): Position => {
|
||||
// image 类型若标记了"允许重叠",保持原坐标以兼容既有行为
|
||||
if (nodeType === 'image' && shouldPreserveImagePosition) return basePosition;
|
||||
const padding =20;
|
||||
const width = size?.width ||300;
|
||||
const height = size?.height ||300;
|
||||
const stepX = width + padding;
|
||||
const stepY = height + padding;
|
||||
|
||||
const overlaps = (pos: Position) => nodes.some(n => {
|
||||
if (n.type !== 'image') return false;
|
||||
const nSize = n.size || { width: 300, height: 300 };
|
||||
return !(
|
||||
pos.x + width + padding <= n.position.x ||
|
||||
pos.x >= n.position.x + nSize.width + padding ||
|
||||
pos.y + height + padding <= n.position.y ||
|
||||
pos.y >= n.position.y + nSize.height + padding
|
||||
);
|
||||
});
|
||||
const overlaps = (pos: Position) => nodes.some(n => {
|
||||
if (n.type !== nodeType) return false;
|
||||
const nSize = n.size || { width:300, height:300 };
|
||||
return !(
|
||||
pos.x + width + padding <= n.position.x ||
|
||||
pos.x >= n.position.x + nSize.width + padding ||
|
||||
pos.y + height + padding <= n.position.y ||
|
||||
pos.y >= n.position.y + nSize.height + padding
|
||||
);
|
||||
});
|
||||
|
||||
if (!overlaps(basePosition)) return basePosition;
|
||||
if (!overlaps(basePosition)) return basePosition;
|
||||
|
||||
const maxRings = 20;
|
||||
for (let ring = 1; ring <= maxRings; ring++) {
|
||||
for (let dx = -ring; dx <= ring; dx++) {
|
||||
for (let dy = -ring; dy <= ring; dy++) {
|
||||
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
||||
const candidate = {
|
||||
x: basePosition.x + dx * stepX,
|
||||
y: basePosition.y + dy * stepY,
|
||||
};
|
||||
if (!overlaps(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const maxRings =20;
|
||||
for (let ring =1; ring <= maxRings; ring++) {
|
||||
for (let dx = -ring; dx <= ring; dx++) {
|
||||
for (let dy = -ring; dy <= ring; dy++) {
|
||||
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
||||
const candidate = {
|
||||
x: basePosition.x + dx * stepX,
|
||||
y: basePosition.y + dy * stepY,
|
||||
};
|
||||
if (!overlaps(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return basePosition;
|
||||
};
|
||||
return basePosition;
|
||||
};
|
||||
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const basePosition = nodeData.position || { x: 0, y: 0 };
|
||||
const nodeSize = nodeData.size || { width: 300, height: 300 };
|
||||
const finalPosition = resolveImagePosition(basePosition, nodeSize, get().nodes);
|
||||
const node: CanvasNode = {
|
||||
...nodeData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
position: finalPosition,
|
||||
};
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const basePosition = nodeData.position || { x:0, y:0 };
|
||||
const nodeSize = nodeData.size || { width:300, height:300 };
|
||||
const finalPosition = resolveAutoPosition(basePosition, nodeSize, get().nodes, nodeData.type);
|
||||
const node: CanvasNode = {
|
||||
...nodeData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
position: finalPosition,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
state.nodes.push(node);
|
||||
if (node.type === 'image') {
|
||||
state.lastAddedNodeId = id;
|
||||
}
|
||||
});
|
||||
set((state) => {
|
||||
state.nodes.push(node);
|
||||
if (node.type === 'image') {
|
||||
state.lastAddedNodeId = id;
|
||||
}
|
||||
});
|
||||
|
||||
// 添加到历史
|
||||
get().pushHistory();
|
||||
// 添加到历史
|
||||
get().pushHistory();
|
||||
|
||||
return id;
|
||||
},
|
||||
return id;
|
||||
},
|
||||
|
||||
//批量添加节点:循环调用 addNode 以复用其避让逻辑,
|
||||
//适用于从历史列表中多选世界/模型添加到画布等场景。
|
||||
addNodesBulk: (nodesData) => {
|
||||
const ids: string[] = [];
|
||||
for (const nodeData of nodesData) {
|
||||
ids.push(get().addNode(nodeData));
|
||||
}
|
||||
return ids;
|
||||
},
|
||||
|
||||
updateNode: (id, updates) => set((state) => {
|
||||
const index = state.nodes.findIndex(n => n.id === id);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user