diff --git a/src/App.tsx b/src/App.tsx index 4765be0..5b03b34 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,11 @@ +import { UndoRedoProvider } from "./UndoRedoContext"; import SceneView from "./pages/SceneView"; import "./styles/main.scss"; -function App() { - return ; +export default function App() { + return ( + + + + ); } - -export default App; diff --git a/src/UndoRedoContext.tsx b/src/UndoRedoContext.tsx new file mode 100644 index 0000000..894e2bc --- /dev/null +++ b/src/UndoRedoContext.tsx @@ -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([]); + const redoStack = useRef([]); + + 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 {children}; +}; diff --git a/src/components/ui/OutlineList .tsx b/src/components/ui/OutlineList .tsx index 544bb8e..a315fbe 100644 --- a/src/components/ui/OutlineList .tsx +++ b/src/components/ui/OutlineList .tsx @@ -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 = ({ width, search, data, + onDrop, + onDragOver, + onRename, + onDragStart, }) => { const [isPanelOpen, setIsPanelOpen] = useState(true); const [draggedNode, setDraggedNode] = useState(null); @@ -27,36 +33,7 @@ export const OutlineList: React.FC = ({ const [selectedObject, setSelectedObject] = useState(null); const [selectedObjects, setSelectedObjects] = useState([]); const [draggedItems, setDraggedItems] = useState([]); - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - - 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 = ({ : [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 = ({ 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 = ({ 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 = ({ ".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 = ({ 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 = ({ }); 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 (
= ({ - - @@ -370,7 +341,6 @@ export const OutlineList: React.FC = ({ setSelectedObjects={setSelectedObjects} selectedObjects={selectedObjects} onRename={handleRename} - pushHistory={pushHistory} hierarchy={hierarchy} setHierarchy={setHierarchy} /> diff --git a/src/components/ui/OutlinePanel.tsx b/src/components/ui/OutlinePanel.tsx index 5ad0fcc..daed898 100644 --- a/src/components/ui/OutlinePanel.tsx +++ b/src/components/ui/OutlinePanel.tsx @@ -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 = (props) => { diff --git a/src/components/ui/TreeNode.tsx b/src/components/ui/TreeNode.tsx index 5941c2c..37b12bc 100644 --- a/src/components/ui/TreeNode.tsx +++ b/src/components/ui/TreeNode.tsx @@ -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>; setSelectedObjects: React.Dispatch>; setHierarchy: React.Dispatch>; setDraggedItems: React.Dispatch>; - pushHistory: (newHierarchy: AssetGroupChild[]) => void; + pushHistory?: (newHierarchy: AssetGroupChild[]) => void; } const TreeNode: React.FC = ({ @@ -67,7 +67,7 @@ const TreeNode: React.FC = ({ 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 = ({ }; toggleVisibilityRecursive(updatedHierarchy); - pushHistory(updatedHierarchy); + pushHistory?.(updatedHierarchy); setHierarchy(updatedHierarchy); }; @@ -180,7 +180,7 @@ const TreeNode: React.FC = ({ }; toggleLockRecursive(updatedHierarchy); - pushHistory(updatedHierarchy); + pushHistory?.(updatedHierarchy); setHierarchy(updatedHierarchy); }; @@ -326,7 +326,6 @@ const TreeNode: React.FC = ({
- {/* Children */} {isExpanded && item.children?.length ? (
{item.children.map((child) => ( @@ -345,7 +344,7 @@ const TreeNode: React.FC = ({ onRename={onRename} draggedItems={draggedItems} setDraggedItems={setDraggedItems} - pushHistory={pushHistory} + // pushHistory={pushHistory} setHierarchy={setHierarchy} hierarchy={hierarchy} /> diff --git a/src/pages/SceneView.tsx b/src/pages/SceneView.tsx index 077df40..c64fda6 100644 --- a/src/pages/SceneView.tsx +++ b/src/pages/SceneView.tsx @@ -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(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 ( ); }; diff --git a/src/useUndoRedo.tsx b/src/useUndoRedo.tsx new file mode 100644 index 0000000..f6be770 --- /dev/null +++ b/src/useUndoRedo.tsx @@ -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(null); + +export const useUndoRedo = () => { + const ctx = useContext(UndoRedoContext); + if (!ctx) { + throw new Error("useUndoRedo must be used inside an UndoRedoProvider"); + } + return ctx; +};