added undo-redo using seperate component

This commit is contained in:
2025-10-27 15:33:48 +05:30
parent caceb7a49d
commit 8a4d677b02
7 changed files with 213 additions and 86 deletions

View File

@@ -1,8 +1,11 @@
import { UndoRedoProvider } from "./UndoRedoContext";
import SceneView from "./pages/SceneView";
import "./styles/main.scss";
function App() {
return <SceneView />;
export default function App() {
return (
<UndoRedoProvider>
<SceneView />
</UndoRedoProvider>
);
}
export default App;

95
src/UndoRedoContext.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { UndoRedoContext, type UndoRedoAction } from "./useUndoRedo";
export const UndoRedoProvider = ({
children,
onUndo,
onRedo,
}: {
children: ReactNode;
onUndo?: () => void;
onRedo?: () => void;
}) => {
const undoStack = useRef<UndoRedoAction[]>([]);
const redoStack = useRef<UndoRedoAction[]>([]);
const [undoShortcut, setUndoShortcut] = useState("z");
const [redoShortcut, setRedoShortcut] = useState("y");
const [renderTick, setRenderTick] = useState(0); // trigger UI updates
const addAction = useCallback((action: UndoRedoAction) => {
undoStack.current.push(action);
redoStack.current = []; // clear redo stack
setRenderTick((t) => t + 1);
}, []);
const undo = useCallback(() => {
const action = undoStack.current.pop();
if (!action) return;
action.undo(); // Call the undo method
redoStack.current.push(action);
setRenderTick((t) => t + 1);
onUndo?.();
}, [onUndo]);
const redo = useCallback(() => {
const action = redoStack.current.pop();
if (!action) return;
action.do(); // Call the do method (redo action)
undoStack.current.push(action);
setRenderTick((t) => t + 1);
onRedo?.();
}, [onRedo]);
const setShortcutKeys = useCallback((undoKey?: string, redoKey?: string) => {
if (undoKey) setUndoShortcut(undoKey);
if (redoKey) setRedoShortcut(redoKey);
}, []);
// safer macOS detection (avoid deprecated navigator.platform)
const isMac = useMemo(() => {
if (typeof navigator === "undefined") return false;
return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent);
}, []);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const ctrlOrCmd = isMac ? e.metaKey : e.ctrlKey;
if (ctrlOrCmd && e.key.toLowerCase() === undoShortcut) {
e.preventDefault();
undo();
} else if (ctrlOrCmd && e.shiftKey && e.key.toLowerCase() === redoShortcut) {
e.preventDefault();
redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undoShortcut, redoShortcut, undo, redo, isMac]);
// 🧠 Memoize context value to prevent re-creation each render
const contextValue = useMemo(
() => ({
addAction,
undo,
redo,
canUndo: undoStack.current.length > 0,
canRedo: redoStack.current.length > 0,
stackCount: {
undo: undoStack.current.length,
redo: redoStack.current.length,
},
setShortcutKeys,
}),
// force update when renderTick changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[renderTick, addAction, undo, redo, setShortcutKeys]
);
return <UndoRedoContext.Provider value={contextValue}>{children}</UndoRedoContext.Provider>;
};

View File

@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { AddIcon, ChevronIcon, CollapseAllIcon } from "../../icons/ExportIcons";
import React, { useEffect, useState } from "react";
import TreeNode from "./TreeNode";
import Search from "./Search";
import { AddIcon, ChevronIcon, CollapseAllIcon } from "../../icons/ExportIcons";
import type { OutlinePanelProps } from "./OutlinePanel";
import { useUndoRedo } from "../../useUndoRedo";
import { DEFAULT_PRAMS, type AssetGroupChild } from "../../data/OutlineListData";
type DropAction = "above" | "child" | "below" | "none";
@@ -19,6 +21,10 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
width,
search,
data,
onDrop,
onDragOver,
onRename,
onDragStart,
}) => {
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [draggedNode, setDraggedNode] = useState<AssetGroupChild | null>(null);
@@ -27,36 +33,7 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
const [selectedObject, setSelectedObject] = useState<AssetGroupChild | null>(null);
const [selectedObjects, setSelectedObjects] = useState<AssetGroupChild[]>([]);
const [draggedItems, setDraggedItems] = useState<AssetGroupChild[]>([]);
const [undoStack, setUndoStack] = useState<AssetGroupChild[][]>([]);
const [redoStack, setRedoStack] = useState<AssetGroupChild[][]>([]);
const pushHistory = (newHierarchy: AssetGroupChild[]) => {
setUndoStack((prev) => [...prev, structuredClone(hierarchy)]);
setRedoStack([]);
setHierarchy(newHierarchy);
};
const handleUndo = useCallback(() => {
if (undoStack.length === 0) return;
const lastState = undoStack.at(-1);
if (!lastState) return;
setRedoStack((prev) => [...prev, structuredClone(hierarchy)]);
setHierarchy(lastState);
setUndoStack((prev) => prev.slice(0, -1));
}, [undoStack, hierarchy]);
const handleRedo = useCallback(() => {
if (redoStack.length === 0) return;
const nextState = redoStack.at(-1);
if (!nextState) return;
setUndoStack((prev) => [...prev, structuredClone(hierarchy)]);
setHierarchy(nextState);
setRedoStack((prev) => prev.slice(0, -1));
}, [redoStack, hierarchy]);
const { addAction, undo, redo, canUndo, canRedo } = useUndoRedo();
const handleDragStart = (item: AssetGroupChild) => {
setDraggedNode(item);
@@ -67,13 +44,10 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
: [item];
setDraggedItems(itemsToDrag);
if (onDragStart) onDragStart();
};
const handleDragOver = (
targetItem: AssetGroupChild,
draggedItem: AssetGroupChild | null,
event: React.DragEvent
) => {
const handleDragOver = (targetItem: AssetGroupChild, event: React.DragEvent) => {
event.preventDefault();
const targetId = targetItem.modelUuid || targetItem.groupUuid;
if (!targetId) return;
@@ -88,6 +62,7 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
const rect = hoveredDiv.getBoundingClientRect();
const y = event.clientY - rect.top;
const dropZone = getDropZone(y);
if (onDragOver) onDragOver();
switch (dropZone || selectedObject === null) {
case "above":
@@ -130,29 +105,22 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
return;
}
const updatedHierarchy = structuredClone(hierarchy);
const prevHierarchy = structuredClone(hierarchy);
const updatedHierarchy = [...hierarchy];
// ✅ Determine which items to move
const itemsToMove =
selectedObjects && selectedObjects.length > 1 ? selectedObjects : [draggedItem];
if (!removeFromHierarchy(draggedItem, updatedHierarchy)) return;
// ✅ Remove and insert each dragged item
itemsToMove.forEach((item) => {
if (!removeFromHierarchy(item, updatedHierarchy)) return;
if (!insertByDropAction(item, targetId, dropAction, updatedHierarchy)) {
updatedHierarchy.push(item);
}
if (!insertByDropAction(draggedItem, targetId, dropAction, updatedHierarchy)) {
updatedHierarchy.push(draggedItem);
}
addAction({
do: () => setHierarchy(updatedHierarchy),
undo: () => setHierarchy(prevHierarchy),
});
// 🧠 Record new state for undo/redo
pushHistory(updatedHierarchy);
// ♻️ Cleanup
setHierarchy(updatedHierarchy);
if (onDrop) onDrop(updatedHierarchy);
setDraggedNode(null);
clearAllHighlights();
setSelectedObject(null);
setSelectedObjects([]);
};
const getDropZone = (y: number): DropAction => {
@@ -167,12 +135,12 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
".tree-node, .tree-node-content, .group-node, .dragging"
);
allNodes.forEach((node) => {
for (const node of allNodes) {
node.style.borderTop = "none";
node.style.borderBottom = "none";
node.style.outline = "none";
node.style.border = "none";
});
}
};
const removeFromHierarchy = (item: AssetGroupChild, tree: AssetGroupChild[]): boolean => {
@@ -265,6 +233,7 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
const filteredHierarchy = filterHierarchy(hierarchy, searchValue);
const handleRename = (id: string, newName: string) => {
const prevHierarchy = structuredClone(hierarchy);
const updateNodeName = (nodes: AssetGroupChild[]): AssetGroupChild[] =>
nodes.map((node) => {
if (node.modelUuid === id || node.groupUuid === id) {
@@ -283,23 +252,25 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
});
const newHierarchy = updateNodeName(structuredClone(hierarchy));
pushHistory(newHierarchy); // record rename
if (onRename) onRename(id, newName);
addAction({
do: () => setHierarchy(newHierarchy),
undo: () => setHierarchy(prevHierarchy),
});
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key.toLowerCase() === "z") {
e.preventDefault();
handleUndo();
undo();
} else if (e.ctrlKey && e.key.toLowerCase() === "y") {
e.preventDefault();
handleRedo();
redo();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [hierarchy, undoStack, redoStack, handleUndo, handleRedo]);
}, [undo, redo]);
return (
<div
@@ -328,10 +299,10 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
<CollapseAllIcon />
</button>
<button onClick={handleUndo} disabled={undoStack.length === 0} title="Undo">
<button onClick={undo} disabled={!canUndo} title="Undo">
</button>
<button onClick={handleRedo} disabled={redoStack.length === 0} title="Redo">
<button onClick={redo} disabled={!canRedo} title="Redo">
🔁
</button>
@@ -370,7 +341,6 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
setSelectedObjects={setSelectedObjects}
selectedObjects={selectedObjects}
onRename={handleRename}
pushHistory={pushHistory}
hierarchy={hierarchy}
setHierarchy={setHierarchy}
/>

View File

@@ -13,6 +13,10 @@ export interface OutlinePanelProps {
width?: string;
search?: boolean;
data: AssetGroupChild[];
onDrop?: (updatedData: AssetGroupChild[]) => void;
onDragStart?: () => void;
onDragOver?: () => void;
onRename?: (id: string, newName: string) => void;
}
const OutlinePanel: React.FC<OutlinePanelProps> = (props) => {

View File

@@ -20,17 +20,17 @@ interface TreeNodeProps {
onDragStart: (item: AssetGroupChild) => void;
onRename: (id: string, item: string) => void;
onDrop: (item: AssetGroupChild, parent: AssetGroupChild | null, e: React.DragEvent) => void;
onDragOver: (item: AssetGroupChild, e: React.DragEvent) => void;
draggingItem: AssetGroupChild | null;
draggedItems: AssetGroupChild[] | [];
hierarchy: AssetGroupChild[] | [];
selectedObject: AssetGroupChild | null;
selectedObjects: AssetGroupChild[] | [];
onDragOver: (item: AssetGroupChild, parent: AssetGroupChild | null, e: React.DragEvent) => void;
setSelectedObject: React.Dispatch<React.SetStateAction<AssetGroupChild | null>>;
setSelectedObjects: React.Dispatch<React.SetStateAction<AssetGroupChild[] | []>>;
setHierarchy: React.Dispatch<React.SetStateAction<AssetGroupChild[] | []>>;
setDraggedItems: React.Dispatch<React.SetStateAction<AssetGroupChild[] | []>>;
pushHistory: (newHierarchy: AssetGroupChild[]) => void;
pushHistory?: (newHierarchy: AssetGroupChild[]) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
@@ -67,7 +67,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
onDragOver(item, draggingItem, e);
onDragOver(item, e);
};
const handleDrop = (e: React.DragEvent) => {
@@ -158,7 +158,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
};
toggleVisibilityRecursive(updatedHierarchy);
pushHistory(updatedHierarchy);
pushHistory?.(updatedHierarchy);
setHierarchy(updatedHierarchy);
};
@@ -180,7 +180,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
};
toggleLockRecursive(updatedHierarchy);
pushHistory(updatedHierarchy);
pushHistory?.(updatedHierarchy);
setHierarchy(updatedHierarchy);
};
@@ -326,7 +326,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
</div>
</div>
{/* Children */}
{isExpanded && item.children?.length ? (
<div className="tree-children">
{item.children.map((child) => (
@@ -345,7 +344,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onRename={onRename}
draggedItems={draggedItems}
setDraggedItems={setDraggedItems}
pushHistory={pushHistory}
// pushHistory={pushHistory}
setHierarchy={setHierarchy}
hierarchy={hierarchy}
/>

View File

@@ -1,18 +1,48 @@
import { useState } from "react";
import OutlinePanel from "../components/ui/OutlinePanel";
import { OutlineListData } from "../data/OutlineListData";
import { OutlineListData, type AssetGroupChild } from "../data/OutlineListData";
const SceneView = () => {
const [outlineData, setOutlineData] = useState<AssetGroupChild[] | []>(OutlineListData);
const handleDragStart = () => {};
const handleDragOver = () => {};
const handleDrop = (updatedData: AssetGroupChild[]) => {
setOutlineData(updatedData);
};
const handleRename = (id: string, newName: string) => {
const renameItem = (items: AssetGroupChild[]): AssetGroupChild[] => {
return items.map((item) => {
if (item.modelUuid === id) {
return { ...item, modelName: newName };
} else if (item.children && item.children.length > 0) {
return { ...item, children: renameItem(item.children) };
}
return item;
});
};
const updatedData = renameItem(outlineData);
setOutlineData(updatedData);
};
return (
<OutlinePanel
// height="500px"
// width="420px"
height="500px"
width="420px"
panelSide="right"
// textColor=""
// addIconColor=""
// eyeIconColor=""
// backgroundColor=""
// search={true}
data={OutlineListData}
textColor=""
addIconColor=""
eyeIconColor=""
backgroundColor=""
search={true}
data={outlineData}
onDrop={handleDrop}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onRename={handleRename}
/>
);
};

26
src/useUndoRedo.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { createContext, useContext } from "react";
export type UndoRedoAction = {
undo: () => void;
do: () => void;
};
export type UndoRedoContextType = {
addAction: (action: UndoRedoAction) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
stackCount: { undo: number; redo: number };
setShortcutKeys?: (undoKey?: string, redoKey?: string) => void;
};
export const UndoRedoContext = createContext<UndoRedoContextType | null>(null);
export const useUndoRedo = () => {
const ctx = useContext(UndoRedoContext);
if (!ctx) {
throw new Error("useUndoRedo must be used inside an UndoRedoProvider");
}
return ctx;
};