488 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|