added undo-redo using seperate component
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -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
95
src/UndoRedoContext.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
26
src/useUndoRedo.tsx
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user