327 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|