2 Commits

Author SHA1 Message Date
894ce91c71 emoved unused functions 2025-10-29 14:39:36 +05:30
e61e2e9794 added delete in contextMenu 2025-10-28 18:16:40 +05:30
10 changed files with 416 additions and 71 deletions

View File

@@ -1,3 +1,4 @@
import { ContextMenuProvider } from "./components/ui/contextMenu/ContextMenuProvider";
import { UndoRedoProvider } from "./components/undoRedo/UndoRedoContext";
import SceneView from "./pages/SceneView";
import "./styles/main.scss";
@@ -5,7 +6,9 @@ import "./styles/main.scss";
export default function App() {
return (
<UndoRedoProvider>
<SceneView />
<ContextMenuProvider>
<SceneView />
</ContextMenuProvider>
</UndoRedoProvider>
);
}

View File

@@ -7,6 +7,7 @@ import type { OutlinePanelProps } from "./OutlinePanel";
import { useUndoRedo } from "../undoRedo/useUndoRedo";
import { DEFAULT_PRAMS, type AssetGroupChild } from "../../data/OutlineListData";
import { useContextMenu } from "./contextMenu/useContextMenu";
type DropAction = "above" | "child" | "below" | "none";
@@ -30,10 +31,14 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
const [draggedNode, setDraggedNode] = useState<AssetGroupChild | null>(null);
const [searchValue, setSearchValue] = useState("");
const [hierarchy, setHierarchy] = useState<AssetGroupChild[]>(data);
useEffect(() => {
setHierarchy(data);
}, [data]);
const [selectedObject, setSelectedObject] = useState<AssetGroupChild | null>(null);
const [selectedObjects, setSelectedObjects] = useState<AssetGroupChild[]>([]);
const [draggedItems, setDraggedItems] = useState<AssetGroupChild[]>([]);
const { addAction, undo, redo, canUndo, canRedo } = useUndoRedo();
const { setMenuPosition, setMenuVisible } = useContextMenu();
const handleDragStart = (item: AssetGroupChild) => {
setDraggedNode(item);
@@ -333,7 +338,14 @@ export const OutlineList: React.FC<OutlinePanelProps> = ({
)}
{isPanelOpen && (
<div className="outline-content">
<div
className="outline-content"
onContextMenu={(e) => {
e.preventDefault();
setMenuVisible(true);
setMenuPosition({ x: e.clientX, y: e.clientY });
}}
>
{filteredHierarchy.map((node) => (
<TreeNode
key={node.groupUuid || node.modelUuid}

View File

@@ -11,6 +11,7 @@ import {
} from "../../icons/ExportIcons";
import { DEFAULT_PRAMS, type AssetGroupChild } from "../../data/OutlineListData";
import { useUndoRedo } from "../undoRedo/useUndoRedo";
import { useContextMenu } from "./contextMenu/useContextMenu";
interface TreeNodeProps {
item: AssetGroupChild;
@@ -59,6 +60,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(item.groupName || item.modelName);
const { addAction } = useUndoRedo();
const { setSelectedId } = useContextMenu();
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
onDragStart(item);
@@ -227,6 +230,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDrop={handleDrop}
id={item.modelUuid || item.groupUuid}
onClick={toggleSelect}
onContextMenu={() => setSelectedId(item?.modelUuid || item?.groupUuid || "")}
>
<div
className="node-wrapper"

View File

@@ -0,0 +1,85 @@
import React from "react";
interface ContextMenuProps {
x: number;
y: number;
visible: boolean;
onClose: () => void;
onMoveToFront?: () => void;
onMoveToBack?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onDelete?: () => void;
textColor?: string;
backgroundColor?: string;
height?: string;
width?: string;
}
const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
visible,
onClose,
onMoveToFront,
onMoveToBack,
onUndo,
onRedo,
onDelete,
backgroundColor,
textColor,
height,
width,
}) => {
if (!visible) return null;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
};
const menuStyle: React.CSSProperties = {
top: y,
left: x,
...(backgroundColor && { background: backgroundColor }),
...(textColor && { color: textColor }),
height: height || "300px",
width: width || "300px",
};
return (
<div
className="context-menu"
onClick={handleClick}
onKeyDown={onClose}
role="menu"
tabIndex={0}
style={menuStyle}
>
<button className="context-menu__item" onClick={onMoveToFront}>
<span className="context-menu__icon"></span>
Move to Front
</button>
<button className="context-menu__item" onClick={onMoveToBack}>
<span className="context-menu__icon"></span>
Move to Back
</button>
<button className="context-menu__item" onClick={onUndo}>
<span className="context-menu__icon"></span>
Undo
</button>
<button className="context-menu__item" onClick={onRedo}>
<span className="context-menu__icon"></span>
Redo
</button>
<button className="context-menu__item context-menu__item--danger" onClick={onDelete}>
<span className="context-menu__icon"></span>
Delete
</button>
</div>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,36 @@
import React, { useState, useCallback, useMemo } from "react";
import { OutlineContext } from "./useContextMenu";
export const ContextMenuProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const [selectedId, setSelectedId] = useState<string | null>(null);
const showMenu = useCallback((e: React.MouseEvent, id: string) => {
e.preventDefault();
setSelectedId(id);
setMenuPosition({ x: e.clientX, y: e.clientY });
setMenuVisible(true);
}, []);
const hideMenu = useCallback(() => {
setMenuVisible(false);
setSelectedId(null);
}, []);
const value = useMemo(
() => ({
menuVisible,
menuPosition,
selectedId,
showMenu,
hideMenu,
setMenuPosition,
setMenuVisible,
setSelectedId,
}),
[menuVisible, menuPosition, selectedId, showMenu, hideMenu]
);
return <OutlineContext.Provider value={value}>{children}</OutlineContext.Provider>;
};

View File

@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
export type ContextMenuContextType = {
menuVisible: boolean;
menuPosition: { x: number; y: number };
selectedId: string | null;
showMenu: (e: React.MouseEvent, id: string) => void;
hideMenu: () => void;
setMenuPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
setMenuVisible: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedId: React.Dispatch<React.SetStateAction<string | null>>;
};
export const OutlineContext = createContext<ContextMenuContextType>({} as ContextMenuContextType);
export const useContextMenu = () => useContext(OutlineContext);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import { UndoRedoContext, type UndoRedoAction } from "./useUndoRedo";
export const UndoRedoProvider = ({
@@ -12,9 +12,6 @@ export const UndoRedoProvider = ({
}) => {
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) => {
@@ -43,36 +40,7 @@ export const UndoRedoProvider = ({
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
// Memoize context value to prevent re-creation each render
const contextValue = useMemo(
() => ({
addAction,
@@ -84,11 +52,10 @@ export const UndoRedoProvider = ({
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]
[renderTick, addAction, undo, redo]
);
return <UndoRedoContext.Provider value={contextValue}>{children}</UndoRedoContext.Provider>;

View File

@@ -1,49 +1,138 @@
import { useState } from "react";
import OutlinePanel from "../components/ui/OutlinePanel";
import React, { useCallback, useEffect, useState } from "react";
import { OutlineListData, type AssetGroupChild } from "../data/OutlineListData";
import OutlinePanel from "../components/ui/OutlinePanel";
import ContextMenu from "../components/ui/contextMenu/ContextMenu";
import { useContextMenu } from "../components/ui/contextMenu/useContextMenu";
import { useUndoRedo } from "../components/undoRedo/useUndoRedo";
const SceneView = () => {
const [outlineData, setOutlineData] = useState<AssetGroupChild[] | []>(OutlineListData);
const { menuVisible, menuPosition, selectedId, hideMenu } = useContextMenu();
const { addAction, undo, redo } = useUndoRedo();
const handleDragStart = () => {};
const handleDragOver = () => {};
const [outlineData, setOutlineData] = useState<AssetGroupChild[]>(OutlineListData);
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);
const renameItem = (items: AssetGroupChild[]): AssetGroupChild[] =>
items.map((item) =>
item.modelUuid === id
? { ...item, modelName: newName }
: item.children
? { ...item, children: renameItem(item.children) }
: item
);
setOutlineData(renameItem(outlineData));
};
const handleDelete = useCallback(
(id: string) => {
setOutlineData((prev) => {
const prevHierarchy = structuredClone(prev);
const updatedHierarchy = prev.filter((obj) => obj.modelUuid !== id);
setTimeout(() => {
addAction({
do: () => setOutlineData(updatedHierarchy),
undo: () => setOutlineData(prevHierarchy),
});
}, 0);
return updatedHierarchy;
});
},
[addAction]
);
const moveToBack = useCallback(
(id: string) => {
setOutlineData((prev) => {
const prevHierarchy = structuredClone(prev);
const item = prev.find((obj) => obj.modelUuid === id);
if (!item) return prev;
const rest = prev.filter((obj) => obj.modelUuid !== id);
const updatedHierarchy = [item, ...rest];
setTimeout(() => {
addAction({
do: () => setOutlineData(updatedHierarchy),
undo: () => setOutlineData(prevHierarchy),
});
}, 0);
return updatedHierarchy;
});
hideMenu();
},
[hideMenu, addAction]
);
const moveToFront = useCallback(
(id: string) => {
setOutlineData((prev) => {
const prevHierarchy = structuredClone(prev);
const item = prev.find((obj) => obj.modelUuid === id);
if (!item) return prev;
const rest = prev.filter((obj) => obj.modelUuid !== id);
const updatedHierarchy = [...rest, item];
// ✅ Schedule undo/redo registration safely (after render)
setTimeout(() => {
addAction({
do: () => setOutlineData(updatedHierarchy),
undo: () => setOutlineData(prevHierarchy),
});
}, 0);
return updatedHierarchy;
});
hideMenu();
},
[hideMenu, addAction]
);
useEffect(() => {
console.log("outlineData: ", outlineData);
}, [outlineData]);
return (
<OutlinePanel
height="500px"
width="420px"
panelSide="right"
textColor=""
addIconColor=""
eyeIconColor=""
backgroundColor=""
search={true}
data={outlineData}
onDrop={handleDrop}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onRename={handleRename}
/>
<>
<OutlinePanel
height="500px"
width="420px"
panelSide="right"
textColor=""
addIconColor=""
eyeIconColor=""
backgroundColor=""
search={true}
data={outlineData}
onDrop={handleDrop}
onRename={handleRename}
/>
<ContextMenu
x={menuPosition.x + 4}
y={menuPosition.y + 4}
height="230px"
width="250px"
textColor=""
backgroundColor=""
visible={menuVisible}
onMoveToFront={() => selectedId && moveToFront(selectedId)}
onMoveToBack={() => selectedId && moveToBack(selectedId)}
onUndo={undo}
onRedo={redo}
onDelete={() => selectedId && handleDelete(selectedId)} // ✅ Add this
onClose={hideMenu}
/>
</>
);
};

View File

@@ -0,0 +1,132 @@
$context-menu-bg: linear-gradient(135deg, #1e1e2f 0%, #12121a 100%);
$context-menu-border: rgba(255, 255, 255, 0.1);
$context-menu-text: rgba(231, 231, 255, 1);
$context-menu-text-secondary: rgba(180, 180, 200, 1);
$context-menu-hover: rgba(255, 255, 255, 0.12);
$context-menu-active: rgba(255, 255, 255, 0.18);
$context-menu-danger: #ff4757;
$context-menu-danger-hover: #ff6b7a;
$context-menu-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
$context-menu-width: 200px;
$context-menu-radius: 10px;
$transition-speed: 0.2s;
$divider-color: rgba(255, 255, 255, 0.08);
.context-menu {
position: absolute;
background: $context-menu-bg;
color: $context-menu-text;
border: 1px solid $context-menu-border;
border-radius: $context-menu-radius;
padding: 8px;
box-shadow: $context-menu-shadow;
z-index: 1000;
width: $context-menu-width;
height: auto;
backdrop-filter: blur(12px);
animation: contextMenuFadeIn $transition-speed ease-out;
user-select: none;
@keyframes contextMenuFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-5px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
&__item {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: all $transition-speed ease;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 0;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transition: width 0.4s ease;
}
&:hover {
background: $context-menu-hover;
transform: translateX(2px);
color: #fff;
&::before {
width: 100%;
}
.context-menu__icon {
transform: scale(1.15);
}
}
&:active {
background: $context-menu-active;
transform: translateX(1px);
}
&--danger {
color: $context-menu-danger;
&:hover {
background: rgba($context-menu-danger, 0.15);
color: $context-menu-danger-hover;
}
.context-menu__icon {
color: $context-menu-danger;
}
}
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 10px;
font-size: 16px;
transition: transform $transition-speed ease;
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
.context-menu {
animation: none;
}
.context-menu__item {
&::before {
display: none;
}
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
.context-menu__icon {
transition: none;
}
}

View File

@@ -1 +1,2 @@
@use "components/outlinePanel.scss";
@use "components/contextMenu/contextMenu.scss";