From 39084322255009b02d209fccebdc87e4e955f7ab Mon Sep 17 00:00:00 2001 From: wass08 Date: Sat, 17 Jan 2026 10:08:19 +0900 Subject: [PATCH] cleaner add + undo/redo state --- apps/editor/components/editor.tsx | 26 +++++++++ bun.lock | 3 + packages/core/package.json | 3 +- packages/core/src/store/use-scene.ts | 84 +++++++++++++++++++++++----- 4 files changed, 100 insertions(+), 16 deletions(-) diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx index 929023d5..d2357610 100644 --- a/apps/editor/components/editor.tsx +++ b/apps/editor/components/editor.tsx @@ -37,6 +37,7 @@ export default function Editor() {
{/* */} + @@ -49,6 +50,31 @@ export default function Editor() { ); } +const TestUndo = () => { + const {undo, redo, futureStates, pastStates} = useScene.temporal.getState(); + + + return (
+ + +
+ ); +} + export const Passes = ({}) => { const { gl: renderer, scene, camera } = useThree(); const postProcessingRef = useRef(null); diff --git a/bun.lock b/bun.lock index 8f942691..03edc3e4 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "mitt": "^3.0.1", "nanoid": "^5.1.6", "zod": "^4.3.5", + "zundo": "^2.3.0", }, "devDependencies": { "@repo/typescript-config": "*", @@ -1072,6 +1073,8 @@ "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zundo": ["zundo@2.3.0", "", { "peerDependencies": { "zustand": "^4.3.0 || ^5.0.0" } }, "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ=="], + "zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], diff --git a/packages/core/package.json b/packages/core/package.json index f1a09c67..b5d7bceb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "dedent": "^1.7.1", "mitt": "^3.0.1", "nanoid": "^5.1.6", - "zod": "^4.3.5" + "zod": "^4.3.5", + "zundo": "^2.3.0" } } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 3b5c7120..dcf920d4 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -5,6 +5,7 @@ import { BuildingNode, ItemNode } from "../schema"; import { LevelNode } from "../schema/nodes/level"; import { WallNode } from "../schema/nodes/wall"; import { AnyNode, AnyNodeId } from "../schema/types"; +import { temporal } from "zundo"; type SceneState = { // 1. The Data: A flat dictionary of all nodes @@ -22,9 +23,13 @@ type SceneState = { clearDirty: (id: AnyNodeId) => void; createNode: (node: AnyNode, parentId?: AnyNodeId) => void; + createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void; }; -const useScene = create()((set, get) => ({ +type PartializedStoreState = Pick; + +const useScene = create()(temporal((set, get) => ({ + // 1. Flat dictionary of all nodes nodes: {}, @@ -126,27 +131,76 @@ const useScene = create()((set, get) => ({ get().dirtyNodes.delete(id); }, - createNode: (node, parentId) => { - set((state) => { - const newNodes = { ...state.nodes, [node.id]: node }; + createNodes: (ops) => { + set((state) => { + const nextNodes = { ...state.nodes }; + const nextRootIds = [...state.rootNodeIds]; - if (parentId) { - const parent = state.nodes[parentId]; - if (parent) { - newNodes[parentId] = { - ...parent, - children: [...parent.children, node.id], - }; + for (const { node, parentId } of ops) { + // 1. Assign parentId to the child (Safe because BaseNode has parentId) + const newNode = { + ...node, + parentId: parentId ?? null + }; + + nextNodes[newNode.id] = newNode; + + // 2. Update the Parent's children list + if (parentId && nextNodes[parentId]) { + const parent = nextNodes[parentId]; + + // Type Guard: Check if the parent node is a container that supports children + if ("children" in parent && Array.isArray(parent.children)) { + nextNodes[parentId] = { + ...parent, + // Use Set to prevent duplicate IDs if createNode is called twice + children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here + }; + } + } else if (!parentId) { + // 3. Handle Root nodes + if (!nextRootIds.includes(newNode.id)) { + nextRootIds.push(newNode.id); + } } } - return { nodes: newNodes }; + return { nodes: nextNodes, rootNodeIds: nextRootIds }; }); - get().markDirty(node.id); - if (parentId) get().markDirty(parentId); - } + // 4. System Sync + ops.forEach(({ node, parentId }) => { + get().markDirty(node.id); + if (parentId) get().markDirty(parentId); + }); +}, + + // 3. The CONVENIENCE (Singular) + createNode: (node, parentId) => get().createNodes([{ node, parentId }]), + + +}), { + partialize: (state) => { + const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history + return { nodes, rootNodeIds} + }, + limit: 50, // Limit to last 50 actions })); useScene.getState().loadScene(); export default useScene; + + +// Subscribe to the temporal store (Undo/Redo events) +useScene.temporal.subscribe((state, prevState) => { + // Check if we just jumped in time (Undo/Redo) + // If the 'nodes' object changed but it wasn't a normal 'set' + const currentNodes = useScene.getState().nodes; + + // Trigger a full scene re-validation + Object.values(currentNodes).forEach(node => { + if (node.type === 'wall') { + useScene.getState().markDirty(node.id); + } + }); +}); \ No newline at end of file