added search functionality with correct alignment of parent child

This commit is contained in:
2025-10-24 13:12:44 +05:30
parent 3f45fda3bb
commit c22fd8ae38
7 changed files with 793 additions and 498 deletions

View File

@@ -1,476 +0,0 @@
import React, { useState } from "react";
import {
AddIcon,
ChevronIcon,
CollapseAllIcon,
CubeIcon,
EyeIcon,
FocusIcon,
FolderIcon,
KebebIcon,
LockIcon,
} from "../../icons/ExportIcons";
import { OutlinePanelProps } from "./OutlinePanel";
import clsx from "clsx";
interface AssetGroupChild {
groupUuid?: string;
groupName?: string;
isExpanded?: boolean;
children?: AssetGroupChild[];
modelUuid?: string;
modelName?: string;
isVisible?: boolean;
isLocked?: boolean;
}
interface TreeNodeProps {
item: AssetGroupChild;
level?: number;
textColor?: string;
eyeIconColor?: string;
lockIconColor?: string;
onDragStart: (item: AssetGroupChild) => void;
onDrop: (item: AssetGroupChild, parent: AssetGroupChild | null, e: any) => void;
draggingItem: AssetGroupChild | null;
selectedObject: string | null;
onDragOver: (item: AssetGroupChild, parent: AssetGroupChild | null, e: React.DragEvent) => void;
setSelectedObject: React.Dispatch<React.SetStateAction<string | null>>;
}
type DropAction = "above" | "child" | "below" | "none";
const DEFAULT_PRAMS = {
backgroundColor: "linear-gradient(to bottom, #1e1e2f, #12121a)",
panelSide: "left",
textColor: "linear-gradient(to bottom, rgba(231, 231, 255, 1), #cacad6ff)",
addIconColor: "white",
lockIconColor: "white",
eyeIconColor: "white",
};
const TreeNode: React.FC<TreeNodeProps> = ({
item,
level = 0,
textColor,
eyeIconColor,
lockIconColor,
onDragStart,
onDrop,
draggingItem,
setSelectedObject,
selectedObject,
onDragOver,
}) => {
const isGroupNode =
Array.isArray(item.children) && item.children.length > 0 ? item.children : false;
const [isExpanded, setIsExpanded] = useState(item.isExpanded || false);
const [isVisible, setIsVisible] = useState(item.isVisible ?? true);
const [isLocked, setIsLocked] = useState(item.isLocked ?? false);
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(item.groupName || item.modelName);
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
onDragStart(item);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
onDragOver(item, draggingItem, e);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
onDrop(item, draggingItem, e);
};
const isBeingDragged =
draggingItem?.groupUuid === item.groupUuid || draggingItem?.modelUuid === item.modelUuid;
const handleDoubleClick = () => {
setIsEditing(true);
};
const handleBlur = () => {
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
setIsEditing(false);
}
};
// Toggle selection (used by mouse click, keyboard and touch)
const toggleSelect = () => {
const currentId = item.modelUuid || item.groupUuid || null;
setSelectedObject((prev) => (prev === currentId ? null : currentId));
};
const handleDivKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.preventDefault();
toggleSelect();
} else if (e.key === "Escape") {
setIsEditing(false);
}
};
const handleTouchStart = () => {
toggleSelect();
};
return (
<div className="tree-node">
<div
onKeyDown={handleDivKeyDown}
onTouchStart={handleTouchStart}
className={clsx("tree-node-content", {
"group-node": isGroupNode,
"asset-node": !isGroupNode,
selected: selectedObject === item.modelUuid,
locked: isLocked,
hidden: !isVisible,
dragging: isBeingDragged,
})}
draggable
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
id={item.modelUuid || item.groupUuid}
onClick={toggleSelect}
>
<div className="node-wrapper" style={{ paddingLeft: `${level * 6 + 1}px` }}>
{isGroupNode && (
<button
className="expand-button"
onClick={(e) => {
e.stopPropagation(); // prevent triggering selection
setIsExpanded(!isExpanded);
}}
>
<ChevronIcon isOpen={isExpanded} />
</button>
)}
<div className="node-icon">
{isGroupNode ? <FolderIcon isOpen={isExpanded} /> : <CubeIcon />}
</div>
<div className="rename-input" onDoubleClick={handleDoubleClick}>
{isEditing ? (
<input
autoFocus
value={name}
className="renaming"
style={{ color: textColor }}
onChange={(e) => setName(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
) : (
<span
className="rename-input"
style={{ color: textColor || DEFAULT_PRAMS.textColor }}
>
{name}
</span>
)}
</div>
<div className="node-controls">
<button
className="control-button"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
>
<EyeIcon
isClosed={!isVisible}
color={eyeIconColor || DEFAULT_PRAMS.eyeIconColor}
/>
</button>
{isGroupNode && item.children?.length ? (
<button className="control-button">
<FocusIcon />
</button>
) : null}
<button
className="control-button"
onClick={(e) => {
e.stopPropagation();
setIsLocked(!isLocked);
}}
>
<LockIcon
isLocked={isLocked}
color={lockIconColor || DEFAULT_PRAMS.lockIconColor}
/>
</button>
{isGroupNode ? (
<button className="control-button">
<KebebIcon />
</button>
) : null}
</div>
</div>
</div>
{isExpanded && item.children?.length ? (
<div className="tree-children" style={{ paddingLeft: "10px" }}>
{item.children.map((child) => (
<TreeNode
key={child.groupUuid || child.modelUuid}
item={child}
level={level + 1}
onDragStart={onDragStart}
onDrop={onDrop}
draggingItem={draggingItem}
selectedObject={selectedObject}
setSelectedObject={setSelectedObject}
onDragOver={onDragOver}
/>
))}
</div>
) : null}
</div>
);
};
export const OutlineList: React.FC<OutlinePanelProps> = ({
backgroundColor = "linear-gradient(to bottom, #1e1e2f, #12121a)",
panelSide = "left",
textColor = "linear-gradient(to bottom, rgba(231, 231, 255, 1), #cacad6ff)",
addIconColor,
lockIconColor,
eyeIconColor,
}) => {
const [selectedObject, setSelectedObject] = useState<string | null>(null); // store UUID
const [isOpen, setIsOpen] = useState(true);
const [draggingItem, setDraggingItem] = useState<AssetGroupChild | null>(null);
const [groupHierarchy, setGroupHierarchy] = useState<AssetGroupChild[]>([
{ modelUuid: "a1", modelName: "Asset 1", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a2", modelName: "Asset 2", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a3", modelName: "Asset 3", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a4", modelName: "Asset 4", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a5", modelName: "Asset 5", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a6", modelName: "Asset 6", isVisible: true, isLocked: false, children: [] },
]);
const handleDragStart = (item: AssetGroupChild) => {
setDraggingItem(item); // Set the dragged item when dragging starts
};
const handleDrop = (
targetItem: AssetGroupChild,
draggedItem: AssetGroupChild | null,
event: DragEvent | React.DragEvent
) => {
if (!draggedItem) return;
const targetId = targetItem.modelUuid;
if (!targetId) return;
const hoveredDiv = document.getElementById(targetId);
if (!hoveredDiv) return;
// Calculate drop position
const rect = hoveredDiv.getBoundingClientRect();
const parentScrollTop = hoveredDiv.parentElement?.scrollTop || 0;
const y = (event as any).clientY - (rect.top + parentScrollTop);
// Determine drop action
const action = getDropAction(y);
if (action === "none") {
hoveredDiv.style.borderTop = "none";
hoveredDiv.style.borderBottom = "none";
hoveredDiv.style.outline = "none";
hoveredDiv.style.border = "none";
setDraggingItem(null);
return;
}
// Update hierarchy
const updatedHierarchy = [...groupHierarchy];
if (!removeItemFromHierarchy(draggedItem, updatedHierarchy)) {
return;
}
if (!insertItemByAction(draggedItem, targetId, action, updatedHierarchy)) {
updatedHierarchy.push(draggedItem);
}
setGroupHierarchy(updatedHierarchy);
setDraggingItem(null);
hoveredDiv.style.borderTop = "none";
hoveredDiv.style.borderBottom = "none";
hoveredDiv.style.outline = "none";
hoveredDiv.style.border = "none";
};
const getDropAction = (y: number): DropAction => {
if (y >= 0 && y < 7) return "above";
if (y >= 7 && y < 19) return "child";
if (y >= 19 && y < 32) return "below";
return "none";
};
const removeItemFromHierarchy = (
item: AssetGroupChild,
hierarchy: AssetGroupChild[]
): boolean => {
for (let i = 0; i < hierarchy.length; i++) {
const current = hierarchy[i];
if (current.modelUuid === item.modelUuid) {
hierarchy.splice(i, 1);
return true;
}
if (current.children?.length) {
const removed = removeItemFromHierarchy(item, current.children);
if (removed) return true;
}
}
return false;
};
const insertItemByAction = (
item: AssetGroupChild,
targetId: string,
action: DropAction,
hierarchy: AssetGroupChild[]
): boolean => {
switch (action) {
case "above":
return insertAsSibling(item, targetId, hierarchy, 0);
case "below":
return insertAsSibling(item, targetId, hierarchy, 1);
case "child":
return addAsChild(targetId, item, hierarchy);
default:
return false;
}
};
const insertAsSibling = (
item: AssetGroupChild,
targetId: string,
hierarchy: AssetGroupChild[],
offset: number // 0 for above, 1 for below
): boolean => {
for (let i = 0; i < hierarchy.length; i++) {
if (hierarchy[i].modelUuid === targetId) {
hierarchy.splice(i + offset, 0, item);
return true;
}
if (hierarchy[i].children?.length) {
const inserted = insertAsSibling(item, targetId, hierarchy[i].children!, offset);
if (inserted) return true;
}
}
return false;
};
const addAsChild = (
parentId: string,
childItem: AssetGroupChild,
hierarchy: AssetGroupChild[]
): boolean => {
for (let i = 0; i < hierarchy.length; i++) {
if (hierarchy[i].modelUuid === parentId) {
if (!hierarchy[i].children) hierarchy[i].children = [];
hierarchy[i].children!.push(childItem);
return true;
}
if (hierarchy[i].children?.length) {
const added = addAsChild(parentId, childItem, hierarchy[i].children!);
if (added) return true;
}
}
return false;
};
const handleDragOver = (
targetItem: AssetGroupChild,
draggedItem: AssetGroupChild | null,
event: DragEvent | React.DragEvent
) => {
event.preventDefault();
const targetId = targetItem?.modelUuid || targetItem?.groupUuid;
if (!targetId) return;
const hoveredDiv = document.getElementById(targetId);
if (!hoveredDiv) return;
hoveredDiv.style.outline = "none";
hoveredDiv.style.borderTop = "none";
hoveredDiv.style.borderBottom = "none";
const rect = hoveredDiv.getBoundingClientRect();
const y = (event as any).clientY - rect.top;
if (y >= 0 && y < 7) {
hoveredDiv.style.borderTop = "2px solid purple";
return "above";
} else if (y >= 19 && y < 32) {
hoveredDiv.style.borderBottom = "2px solid purple";
return "below";
} else {
hoveredDiv.style.outline = "2px solid #b188ff";
return "child";
}
};
return (
<div
className="outline-overlay"
style={{
justifyContent: panelSide === "left" ? "flex-start" : "flex-end",
}}
>
<div
className="outline-card"
style={{
background: backgroundColor || DEFAULT_PRAMS.backgroundColor,
}}
>
<div className="outline-header">
<div className="header-title">
<p>Outline</p>
</div>
<div className="outline-toolbar">
<button className="toolbar-button">
<AddIcon color={addIconColor || DEFAULT_PRAMS.addIconColor} />
</button>
<button className="toolbar-button">
<CollapseAllIcon />
</button>
<button className="close-button" onClick={() => setIsOpen(!isOpen)}>
<ChevronIcon isOpen={isOpen} />
</button>
</div>
</div>
{isOpen && (
<div className="outline-content">
{groupHierarchy.map((item) => (
<TreeNode
key={item.groupUuid || item.modelUuid}
item={item}
textColor={textColor}
eyeIconColor={eyeIconColor}
lockIconColor={lockIconColor}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragOver={handleDragOver}
draggingItem={draggingItem}
selectedObject={selectedObject}
setSelectedObject={setSelectedObject}
/>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,338 @@
import React, { useEffect, useState } from "react";
import { AddIcon, ChevronIcon, CollapseAllIcon } from "../../icons/ExportIcons";
import { OutlinePanelProps } from "./OutlinePanel";
import TreeNode from "./TreeNode";
import Search from "./Search";
export interface AssetGroupChild {
groupUuid?: string;
groupName?: string;
isExpanded?: boolean;
children?: AssetGroupChild[];
modelUuid?: string;
modelName?: string;
isVisible?: boolean;
isLocked?: boolean;
}
type DropAction = "above" | "child" | "below" | "none";
export const DEFAULT_PRAMS = {
backgroundColor: "linear-gradient(to bottom, #1e1e2f, #12121a)",
panelSide: "left",
textColor: "linear-gradient(to bottom, rgba(231, 231, 255, 1), #cacad6ff)",
addIconColor: "white",
lockIconColor: "white",
eyeIconColor: "white",
height: "800px",
width: "280px",
search: false,
};
export const OutlineList: React.FC<OutlinePanelProps> = ({
backgroundColor = "linear-gradient(to bottom, #1e1e2f, #12121a)",
panelSide = "left",
textColor = "linear-gradient(to bottom, rgba(231, 231, 255, 1), #cacad6ff)",
addIconColor,
lockIconColor,
eyeIconColor,
height,
width,
search,
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [draggedNode, setDraggedNode] = useState<AssetGroupChild | null>(null);
const [searchValue, setSearchValue] = useState("");
const [hierarchy, setHierarchy] = useState<AssetGroupChild[]>([
{ modelUuid: "a1", modelName: "Asset 1", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a2", modelName: "Asset 2", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a3", modelName: "Asset 3", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a4", modelName: "Asset 4", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a5", modelName: "Asset 5", isVisible: true, isLocked: false, children: [] },
{ modelUuid: "a6", modelName: "Asset 6", isVisible: true, isLocked: false, children: [] },
]);
const handleDragStart = (item: AssetGroupChild) => {
setDraggedNode(item);
};
const handleDragOver = (
targetItem: AssetGroupChild,
draggedItem: AssetGroupChild | null,
event: React.DragEvent
) => {
event.preventDefault();
const targetId = targetItem.modelUuid || targetItem.groupUuid;
if (!targetId) return;
const hoveredDiv = document.getElementById(targetId);
if (!hoveredDiv) return;
hoveredDiv.style.outline = "none";
hoveredDiv.style.borderTop = "none";
hoveredDiv.style.borderBottom = "none";
const rect = hoveredDiv.getBoundingClientRect();
const y = event.clientY - rect.top;
const dropZone = getDropZone(y);
switch (dropZone || selectedId === null) {
case "above":
hoveredDiv.style.borderTop = "2px solid purple";
break;
case "below":
hoveredDiv.style.borderBottom = "2px solid purple";
break;
case "child":
hoveredDiv.style.outline = "2px solid #b188ff";
break;
default:
break;
}
return dropZone;
};
const handleDrop = (
targetItem: AssetGroupChild,
draggedItem: AssetGroupChild | null,
event: React.DragEvent
) => {
if (!draggedItem) return;
const targetId = targetItem.modelUuid;
if (!targetId) return;
const hoveredDiv = document.getElementById(targetId);
if (!hoveredDiv) return;
const rect = hoveredDiv.getBoundingClientRect();
const parentScrollTop = hoveredDiv.parentElement?.scrollTop || 0;
const y = event.clientY - (rect.top + parentScrollTop);
const dropAction = getDropZone(y);
if (dropAction === "none") {
clearHighlight(hoveredDiv);
setDraggedNode(null);
return;
}
const updatedHierarchy = [...hierarchy];
// remove old reference
if (!removeFromHierarchy(draggedItem, updatedHierarchy)) return;
// insert in new location
if (!insertByDropAction(draggedItem, targetId, dropAction, updatedHierarchy)) {
updatedHierarchy.push(draggedItem);
}
setHierarchy(updatedHierarchy);
setDraggedNode(null);
clearHighlight(hoveredDiv);
};
const getDropZone = (y: number): DropAction => {
if (y < 7) return "above";
if (y >= 7 && y < 19) return "child";
if (y >= 19 && y < 32) return "below";
return "none";
};
const clearHighlight = (el: HTMLElement) => {
el.style.borderTop = "none";
el.style.borderBottom = "none";
el.style.outline = "none";
el.style.border = "none";
};
const removeFromHierarchy = (item: AssetGroupChild, tree: AssetGroupChild[]): boolean => {
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
if (node.modelUuid === item.modelUuid) {
tree.splice(i, 1);
return true;
}
if (node.children?.length && removeFromHierarchy(item, node.children)) {
return true;
}
}
return false;
};
const insertByDropAction = (
item: AssetGroupChild,
targetId: string,
action: DropAction,
tree: AssetGroupChild[]
): boolean => {
switch (action) {
case "above":
return insertAsSibling(item, targetId, tree, 0);
case "below":
return insertAsSibling(item, targetId, tree, 1);
case "child":
return insertAsChild(targetId, item, tree);
default:
return false;
}
};
const insertAsSibling = (
item: AssetGroupChild,
targetId: string,
tree: AssetGroupChild[],
offset: number
): boolean => {
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
if (node.modelUuid === targetId) {
tree.splice(i + offset, 0, item);
return true;
}
if (node.children?.length && insertAsSibling(item, targetId, node.children, offset)) {
return true;
}
}
return false;
};
const insertAsChild = (
parentId: string,
child: AssetGroupChild,
tree: AssetGroupChild[]
): boolean => {
for (let node of tree) {
if (node.modelUuid === parentId) {
node.children = node.children || [];
node.children.push(child);
return true;
}
if (node.children?.length && insertAsChild(parentId, child, node.children)) {
return true;
}
}
return false;
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent): void => {
const target = event.target as HTMLElement;
if (!target.closest(".outline-card")) {
setSelectedId(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const filterHierarchy = (items: AssetGroupChild[], query: string): AssetGroupChild[] => {
if (!query.trim()) return items;
return items
.map((item) => {
const matches =
item.modelName?.toLowerCase().includes(query.toLowerCase()) ||
item.groupName?.toLowerCase().includes(query.toLowerCase());
const filteredChildren = item.children ? filterHierarchy(item.children, query) : [];
// If this node or any of its children match, keep it
if (matches || filteredChildren.length > 0) {
return { ...item, children: filteredChildren };
}
return null;
})
.filter(Boolean) as AssetGroupChild[];
};
const filteredHierarchy = filterHierarchy(hierarchy, searchValue);
const handleRename = (id: string, newName: string) => {
const updateNodeName = (nodes: AssetGroupChild[]): AssetGroupChild[] =>
nodes.map((node) => {
if (node.modelUuid === id || node.groupUuid === id) {
return {
...node,
modelName: node.modelName ? newName : node.modelName,
groupName: node.groupName ? newName : node.groupName,
};
}
if (node.children?.length) {
return { ...node, children: updateNodeName(node.children) };
}
return node;
});
setHierarchy((prev) => updateNodeName(prev));
};
return (
<div
className="outline-overlay"
style={{
justifyContent: panelSide === "left" ? "flex-start" : "flex-end",
}}
>
<div
className="outline-card"
style={{
background: backgroundColor,
height: height || DEFAULT_PRAMS.height,
width: width || DEFAULT_PRAMS.width,
}}
>
<div className="outline-header">
<div className="header-title">
<p>Outline</p>
</div>
<div className="outline-toolbar">
<button className="toolbar-button">
<AddIcon color={addIconColor} />
</button>
<button className="toolbar-button">
<CollapseAllIcon />
</button>
<button
className="close-button"
onClick={() => setIsPanelOpen(!isPanelOpen)}
>
<ChevronIcon isOpen={isPanelOpen} />
</button>
</div>
</div>
{search && (
<div className="search-overlay">
<Search onChange={(val) => setSearchValue(val)} />
</div>
)}
{/* List */}
{isPanelOpen && (
<div className="outline-content">
{filteredHierarchy.map((node) => (
<TreeNode
key={node.groupUuid || node.modelUuid}
item={node}
textColor={textColor}
eyeIconColor={eyeIconColor}
lockIconColor={lockIconColor}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragOver={handleDragOver}
draggingItem={draggedNode}
selectedObject={selectedId}
setSelectedObject={setSelectedId}
onRename={handleRename}
/>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -8,6 +8,9 @@ export interface OutlinePanelProps {
addIconColor?: string;
eyeIconColor?: string;
lockIconColor?: string;
height?: string;
width?: string;
search?: boolean;
}
const OutlinePanel: React.FC<OutlinePanelProps> = (props) => {

View File

@@ -0,0 +1,85 @@
import React, { ChangeEvent, useEffect, useState } from "react";
import { CloseIcon, SearchIcon } from "../../icons/ExportIcons";
interface SearchProps {
value?: string | null;
placeholder?: string;
onChange?: (value: string) => void;
debounced?: boolean;
}
const Search: React.FC<SearchProps> = ({
value = "",
placeholder = "Search",
onChange,
debounced = false,
}) => {
const [inputValue, setInputValue] = useState(value);
const [isFocused, setIsFocused] = useState(false);
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setInputValue(newValue);
if (!debounced) {
onChange?.(newValue);
}
};
useEffect(() => {
if (value === null) {
setInputValue("");
handleBlur();
}
}, [value]);
useEffect(() => {
if (!debounced) return;
const timer = setTimeout(() => {
onChange?.(inputValue ?? "");
}, 500);
return () => clearTimeout(timer);
}, [inputValue, debounced, onChange]);
const handleClear = () => {
console.warn("Search field cleared.");
setInputValue("");
onChange?.("");
};
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
};
return (
<div className="search-wrapper">
<div className={`search-container ${isFocused || inputValue ? "active" : ""}`}>
<div className="icon-container">
<SearchIcon />
</div>
<input
type="text"
className="search-input"
value={inputValue ?? ""}
placeholder={placeholder}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
/>
{inputValue && (
<button id="clear-button" className="clear-button" onClick={handleClear}>
<CloseIcon />
</button>
)}
</div>
</div>
);
};
export default Search;

View File

@@ -0,0 +1,254 @@
import { useState } from "react";
import { AssetGroupChild, DEFAULT_PRAMS } from "./OutlineList ";
import clsx from "clsx";
import {
ChevronIcon,
CubeIcon,
EyeIcon,
FocusIcon,
FolderIcon,
KebebIcon,
LockIcon,
} from "../../icons/ExportIcons";
interface TreeNodeProps {
item: AssetGroupChild;
level?: number;
textColor?: string;
eyeIconColor?: string;
lockIconColor?: string;
onDragStart: (item: AssetGroupChild) => void;
onRename: (id: string, item: string) => void;
onDrop: (item: AssetGroupChild, parent: AssetGroupChild | null, e: any) => void;
draggingItem: AssetGroupChild | null;
selectedObject: string | null;
onDragOver: (item: AssetGroupChild, parent: AssetGroupChild | null, e: React.DragEvent) => void;
setSelectedObject: React.Dispatch<React.SetStateAction<string | null>>;
}
const TreeNode: React.FC<TreeNodeProps> = ({
item,
level = 0,
textColor,
eyeIconColor,
lockIconColor,
onDragStart,
onDrop,
draggingItem,
setSelectedObject,
selectedObject,
onDragOver,
onRename,
}) => {
const isGroupNode =
Array.isArray(item.children) && item.children.length > 0 ? item.children : false;
const [isExpanded, setIsExpanded] = useState(item.isExpanded || false);
const [isVisible, setIsVisible] = useState(item.isVisible ?? true);
const [isLocked, setIsLocked] = useState(item.isLocked ?? false);
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(item.groupName || item.modelName);
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
onDragStart(item);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
onDragOver(item, draggingItem, e);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
onDrop(item, draggingItem, e);
};
const isBeingDragged =
draggingItem?.groupUuid === item.groupUuid || draggingItem?.modelUuid === item.modelUuid;
const handleDoubleClick = () => {
setIsEditing(true);
};
const handleBlur = () => {
setIsEditing(false);
onRename?.(item.modelUuid || item.groupUuid || "", name || "");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
setIsEditing(false);
onRename?.(item.modelUuid || item.groupUuid || "", name || "");
}
};
// Toggle selection (used by mouse click, keyboard and touch)
const toggleSelect = () => {
const currentId = item.modelUuid || item.groupUuid || null;
setSelectedObject((prev) => (prev === currentId ? null : currentId));
};
const handleDivKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.preventDefault();
toggleSelect();
} else if (e.key === "Escape") {
setIsEditing(false);
}
};
const handleTouchStart = () => {
toggleSelect();
};
return (
<div className="tree-node">
<div
onKeyDown={handleDivKeyDown}
onTouchStart={handleTouchStart}
className={clsx("tree-node-content", {
"group-node": isGroupNode,
"asset-node": !isGroupNode,
selected: selectedObject === item.modelUuid,
locked: isLocked,
hidden: !isVisible,
dragging: isBeingDragged,
})}
draggable
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
id={item.modelUuid || item.groupUuid}
onClick={toggleSelect}
>
{/* 👇 Flex container for the entire row */}
<div
className="node-wrapper"
style={{ display: "flex", alignItems: "center", width: "100%" }}
>
{/* Indentation spacer — only affects left side */}
<div style={{ width: `${level * 20}px`, flexShrink: 0 }} />
{/* Expand button (only for groups) */}
{isGroupNode ? (
<button
className="expand-button"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
<ChevronIcon isOpen={isExpanded} />
</button>
) : (
<div style={{ width: "20px", flexShrink: 0 }} /> // match expand button width
)}
<div className="node-icon" style={{ flexShrink: 0 }}>
{isGroupNode ? <FolderIcon isOpen={isExpanded} /> : <CubeIcon />}
</div>
<div
className="rename-input"
onDoubleClick={handleDoubleClick}
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
>
{isEditing ? (
<input
autoFocus
value={name}
className="renaming"
style={{
color: textColor,
width: "100%",
border: "none",
background: "transparent",
}}
onChange={(e) => setName(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
) : (
<span
className="rename-input"
title={name}
style={{
color: textColor || DEFAULT_PRAMS.textColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
width: "100%",
}}
>
{name}
</span>
)}
</div>
{/* Controls (always visible, never shrink) */}
<div
className="node-controls"
style={{ display: "flex", gap: "4px", flexShrink: 0 }}
>
<button
className="control-button"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
>
<EyeIcon
isClosed={!isVisible}
color={eyeIconColor || DEFAULT_PRAMS.eyeIconColor}
/>
</button>
{isGroupNode && item.children?.length ? (
<button className="control-button">
<FocusIcon />
</button>
) : null}
<button
className="control-button"
onClick={(e) => {
e.stopPropagation();
setIsLocked(!isLocked);
}}
>
<LockIcon
isLocked={isLocked}
color={lockIconColor || DEFAULT_PRAMS.lockIconColor}
/>
</button>
{isGroupNode ? (
<button className="control-button">
<KebebIcon />
</button>
) : null}
</div>
</div>
</div>
{/* Children */}
{isExpanded && item.children?.length ? (
<div className="tree-children">
{item.children.map((child) => (
<TreeNode
key={child.groupUuid || child.modelUuid}
item={child}
level={level + 1}
onDragStart={onDragStart}
onDrop={onDrop}
draggingItem={draggingItem}
selectedObject={selectedObject}
setSelectedObject={setSelectedObject}
onDragOver={onDragOver}
onRename={onRename}
/>
))}
</div>
) : null}
</div>
);
};
export default TreeNode;

View File

@@ -1,14 +1,16 @@
import React from "react";
import OutlinePanel from "../components/outlinePanel/OutlinePanel";
import OutlinePanel from "../components/ui/OutlinePanel";
const SceneView = () => {
return (
<OutlinePanel
// panelSide="right"
// textColor=""
// addIconColor=""
// eyeIconColor=""
// backgroundColor=""
// height="1000px"
// width="120px"
// panelSide="right"
// textColor=""
// addIconColor=""
// eyeIconColor=""
// backgroundColor=""
search={true}
/>
);
};

View File

@@ -1,3 +1,35 @@
$default-bg: #1e1e1e;
$default-text: #cccccc;
$border-color: #3e3e3e;
$hover-bg: #2a2a2a;
$selected-bg: #094771;
$active-bg: #0e639c;
$dragging-opacity: 0.5;
$transition-speed: 0.2s;
$icon-size: 16px;
$node-height: 28px;
$spacing-unit: 8px;
// Mixins
@mixin flex-center {
display: flex;
align-items: center;
}
@mixin button-reset {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
outline: none;
color: inherit;
}
@mixin smooth-transition($properties...) {
transition: $properties $transition-speed ease;
}
.outline-overlay {
padding: 0 4px;
font-family: "Segoe UI", sans-serif;
@@ -5,24 +37,91 @@
position: absolute;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
z-index: 1000;
}
.outline-card {
pointer-events: all;
width: 280px;
max-width: 100%;
border-radius: 10px;
border: 1px solid #2e2e3e;
background: linear-gradient(to bottom, #1e1e2f, #12121a);
box-shadow: 0 0 8px rgba(168, 85, 247, 0.2);
box-shadow: 0 0 8px rgba(150, 83, 212, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-overlay {
// Search Wrapper
.search-wrapper {
width: 100%;
position: sticky;
top: 0;
width: 100%;
z-index: 1;
.search-container {
@include flex-center;
background: $hover-bg;
border: 1px solid $border-color;
border-radius: 20px;
padding: 6px;
gap: $spacing-unit;
@include smooth-transition(border-color, background-color);
&.active,
&:focus-within {
border-color: #b566ff;
background: #262626;
}
.icon-container {
@include flex-center;
flex-shrink: 0;
width: $icon-size;
height: $icon-size;
opacity: 0.6;
svg {
width: 100%;
height: 100%;
}
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: $default-text;
font-size: 13px;
font-family: inherit;
&::placeholder {
color: #888;
}
}
.clear-button {
@include button-reset;
@include flex-center;
width: $icon-size;
height: $icon-size;
opacity: 0.6;
@include smooth-transition(opacity);
&:hover {
opacity: 1;
}
svg {
width: 100%;
height: 100%;
}
}
}
}
}
}
.outline-header {
display: flex;
align-items: center;
@@ -92,15 +191,6 @@
max-height: 52vh;
overflow-y: auto;
padding: 8px 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #a855f7;
border-radius: 10px;
}
}
.tree-node {
@@ -135,7 +225,6 @@
> div {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}