Files
explorer/src/components/outlinePanel/OutlineList .tsx
2025-10-23 17:14:20 +05:30

488 lines
17 KiB
TypeScript

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(isGroupNode ? 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>) => {
// support Enter and Space for accessibility
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleSelect();
}
};
const handleTouchStart = () => {
toggleSelect();
};
return (
<div className="tree-node">
<div
tabIndex={0}
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 * 25 + 8}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">
{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);
}
// Commit changes
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;
// Remove previous outlines before applying new one
hoveredDiv.style.outline = "none";
hoveredDiv.style.borderTop = "none";
hoveredDiv.style.borderBottom = "none";
const rect = hoveredDiv.getBoundingClientRect();
const y = (event as any).clientY - rect.top;
// Determine where the user is hovering
if (y >= 0 && y < 7) {
// Top region
console.log("Top: ");
hoveredDiv.style.borderTop = "2px solid purple";
return "above";
} else if (y >= 19 && y < 32) {
// Bottom region
console.log("Bottom: ");
hoveredDiv.style.borderBottom = "2px solid purple";
return "below";
} else {
// Middle region (child)
console.log("Middle: ");
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} // pass UUID
setSelectedObject={setSelectedObject} // pass setter
/>
))}
</div>
)}
</div>
</div>
);
};