Files
explorer/src/components/ui/OutlineList .tsx

327 lines
11 KiB
TypeScript

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";
import { AssetGroupChild } from "../../data/OutlineListData";
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,
data,
}) => {
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[]>(data);
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 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") {
clearAllHighlights();
setDraggedNode(null);
return;
}
const updatedHierarchy = [...hierarchy];
if (!removeFromHierarchy(draggedItem, updatedHierarchy)) return;
if (!insertByDropAction(draggedItem, targetId, dropAction, updatedHierarchy)) {
updatedHierarchy.push(draggedItem);
}
setHierarchy(updatedHierarchy);
setDraggedNode(null);
clearAllHighlights();
};
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 clearAllHighlights = () => {
const allNodes = document.querySelectorAll<HTMLElement>(
".tree-node, .tree-node-content, .group-node, .dragging"
);
allNodes.forEach((node) => {
node.style.borderTop = "none";
node.style.borderBottom = "none";
node.style.outline = "none";
node.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;
};
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 (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>
)}
{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>
);
};