Merge remote-tracking branch 'origin/main-dev' into feature/layout-comparison-version

This commit is contained in:
2025-10-14 10:23:42 +05:30
9 changed files with 333 additions and 280 deletions

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import ToggleHeader from "../../ui/inputs/ToggleHeader"; import ToggleHeader from "../../ui/inputs/ToggleHeader";
// import Outline from "./Outline";
import Header from "./Header"; import Header from "./Header";
import { useToggleStore } from "../../../store/ui/useUIToggleStore"; import { useToggleStore } from "../../../store/ui/useUIToggleStore";
import Assets from "./assetList/Assets"; import Assets from "./assetList/Assets";
@@ -9,7 +8,7 @@ import Widgets from "./visualization/widgets/Widgets";
import Templates from "../../../modules/visualization/template/Templates"; import Templates from "../../../modules/visualization/template/Templates";
import Search from "../../ui/inputs/Search"; import Search from "../../ui/inputs/Search";
import { useIsComparing } from "../../../store/builder/store"; import { useIsComparing } from "../../../store/builder/store";
import { Outline } from "../../../modules/builder/testUi/outline"; import { AssetOutline } from "../../ui/list/OutlineList/AssetOutline";
const SideBarLeft: React.FC = () => { const SideBarLeft: React.FC = () => {
const [activeOption, setActiveOption] = useState("Widgets"); const [activeOption, setActiveOption] = useState("Widgets");
@@ -54,7 +53,7 @@ const SideBarLeft: React.FC = () => {
return ( return (
<> <>
<ToggleHeader options={["Outline", "Assets"]} activeOption={activeOption} handleClick={handleToggleClick} /> <ToggleHeader options={["Outline", "Assets"]} activeOption={activeOption} handleClick={handleToggleClick} />
<div className="sidebar-left-content-container">{activeOption === "Outline" ? <Outline /> : <Assets />}</div> <div className="sidebar-left-content-container">{activeOption === "Outline" ? <AssetOutline /> : <Assets />}</div>
</> </>
); );
} else { } else {
@@ -63,7 +62,7 @@ const SideBarLeft: React.FC = () => {
{!isComparing && ( {!isComparing && (
<> <>
<ToggleHeader options={["Outline"]} activeOption={activeOption} handleClick={handleToggleClick} /> <ToggleHeader options={["Outline"]} activeOption={activeOption} handleClick={handleToggleClick} />
<div className="sidebar-left-content-container">{activeOption === "Outline" ? <Outline /> : <Assets />}</div> <div className="sidebar-left-content-container">{activeOption === "Outline" ? <AssetOutline /> : <Assets />}</div>
</> </>
)} )}
</> </>
@@ -78,4 +77,4 @@ const SideBarLeft: React.FC = () => {
export default SideBarLeft; export default SideBarLeft;
// sidebar-left-container opemn close sidebar-left-container smoothly // sidebar-left-container open close sidebar-left-container smoothly

View File

@@ -1,25 +1,17 @@
import { useState, useRef, DragEvent, useCallback } from "react"; import { useState, useRef, DragEvent, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { import { EyeIcon, LockIcon, FolderIcon, ChevronIcon, CubeIcon, AddIcon, KebebIcon, CollapseAllIcon, FocusIcon, DeleteIcon } from "../../../icons/ExportCommonIcons";
EyeIcon, import RenameInput from "../../inputs/RenameInput";
LockIcon,
FolderIcon,
ChevronIcon,
CubeIcon,
AddIcon,
KebebIcon,
CollapseAllIcon,
FocusIcon,
} from "../../../components/icons/ExportCommonIcons";
import RenameInput from "../../../components/ui/inputs/RenameInput";
import { useSceneContext } from "../../scene/sceneContext";
import { useSocketStore } from "../../../store/socket/useSocketStore";
import useAssetResponseHandler from "../../collaboration/responseHandler/useAssetResponseHandler";
import { getUserData } from "../../../functions/getUserData";
import { setAssetsApi } from "../../../services/factoryBuilder/asset/floorAsset/setAssetsApi";
import clsx from "clsx"; import clsx from "clsx";
import { useContextActionStore } from "../../../store/builder/store"; import { useSceneContext } from "../../../../modules/scene/sceneContext";
import { useSocketStore } from "../../../../store/socket/useSocketStore";
import useAssetResponseHandler from "../../../../modules/collaboration/responseHandler/useAssetResponseHandler";
import useZoomMesh from "../../../../modules/builder/hooks/useZoomMesh";
import { getUserData } from "../../../../functions/getUserData";
import { useOuterClick } from "../../../../utils/useOuterClick";
import { setAssetsApi } from "../../../../services/factoryBuilder/asset/floorAsset/setAssetsApi";
interface DragState { interface DragState {
draggedItem: AssetGroupChild | null; draggedItem: AssetGroupChild | null;
@@ -28,7 +20,7 @@ interface DragState {
isRootTarget: boolean; isRootTarget: boolean;
} }
// Tree Node Component // Enhanced Tree Node Component with Group Selection
const TreeNode = ({ const TreeNode = ({
item, item,
level = 0, level = 0,
@@ -54,7 +46,7 @@ const TreeNode = ({
}) => { }) => {
const { assetGroupStore, assetStore } = useSceneContext(); const { assetGroupStore, assetStore } = useSceneContext();
const { hasSelectedAsset, selectedAssets } = assetStore(); const { hasSelectedAsset, selectedAssets } = assetStore();
const { isGroup, getGroupsContainingAsset, getGroupsContainingGroup } = assetGroupStore(); const { isGroup, hasSelectedGroup, selectedGroups, getParentGroup } = assetGroupStore();
const isGroupNode = isGroup(item); const isGroupNode = isGroup(item);
const itemId = isGroupNode ? item.groupUuid : item.modelUuid; const itemId = isGroupNode ? item.groupUuid : item.modelUuid;
@@ -62,32 +54,26 @@ const TreeNode = ({
const isVisible = item.isVisible; const isVisible = item.isVisible;
const isLocked = item.isLocked; const isLocked = item.isLocked;
const isExpanded = isGroupNode ? item.isExpanded : false; const isExpanded = isGroupNode ? item.isExpanded : false;
const isSelected = !isGroupNode ? hasSelectedAsset(item.modelUuid) : false;
const isMultiSelected =
!isGroupNode && selectedAssets.length > 1 && hasSelectedAsset(item.modelUuid);
// Determine the parent group of this item const isSelected = isGroupNode ? hasSelectedGroup(item.groupUuid) : hasSelectedAsset(item.modelUuid);
const getParentGroup = useCallback(
(currentItem: AssetGroupChild): string | null => { const getMultiSelectionState = (item: AssetGroupChild) => {
if (isGroup(currentItem)) { const totalSelectedItems = selectedGroups.length + selectedAssets.length;
const parents = getGroupsContainingGroup(currentItem.groupUuid);
return parents.length > 0 ? parents[0].groupUuid : null; if (totalSelectedItems <= 1) return false;
if (isGroup(item)) {
return selectedGroups.includes(item.groupUuid);
} else { } else {
const parents = getGroupsContainingAsset(currentItem.modelUuid); return hasSelectedAsset(item.modelUuid);
return parents.length > 0 ? parents[0].groupUuid : null;
} }
}, };
[getGroupsContainingAsset, getGroupsContainingGroup, isGroup]
); const isMultiSelected = getMultiSelectionState(item);
// Check if this node should be highlighted as a drop target
const isDropTarget = useCallback(() => { const isDropTarget = useCallback(() => {
if (!dragState.draggedItem || !dragState.targetGroupUuid || !isGroupNode) return false; if (!dragState.draggedItem || !dragState.targetGroupUuid || !isGroupNode) return false;
// Get the group UUID this item belongs to or is
const thisGroupUuid = item.groupUuid; const thisGroupUuid = item.groupUuid;
// Highlight if this is the target group or belongs to the target group
return thisGroupUuid === dragState.targetGroupUuid; return thisGroupUuid === dragState.targetGroupUuid;
}, [dragState, isGroupNode, item]); }, [dragState, isGroupNode, item]);
@@ -123,12 +109,11 @@ const TreeNode = ({
const shouldShowHighlight = isDropTarget(); const shouldShowHighlight = isDropTarget();
return ( return (
<div <div key={itemId} className={`tree-node ${shouldShowHighlight ? "drop-target-highlight" : ""} ${isGroupNode && isSelected ? "group-selected" : ""}`}>
key={itemId}
className={`tree-node ${shouldShowHighlight ? "drop-target-highlight" : ""}`}
>
<div <div
className={clsx("tree-node-content", { className={clsx("tree-node-content", {
"group-node": isGroupNode,
"asset-node": !isGroupNode,
locked: isLocked, locked: isLocked,
hidden: !isVisible, hidden: !isVisible,
dragging: dragState.draggedItem === item, dragging: dragState.draggedItem === item,
@@ -144,55 +129,29 @@ const TreeNode = ({
onClick={handleNodeClick} onClick={handleNodeClick}
> >
{isGroupNode && ( {isGroupNode && (
<button <button className="expand-button" onClick={() => onToggleExpand(item.groupUuid, !isExpanded)}>
className="expand-button"
onClick={() => onToggleExpand(item.groupUuid, !isExpanded)}
>
<ChevronIcon isOpen={isExpanded} /> <ChevronIcon isOpen={isExpanded} />
</button> </button>
)} )}
<div className="node-icon"> <div className="node-icon">{isGroupNode ? <FolderIcon isOpen={isExpanded} /> : <CubeIcon />}</div>
{isGroupNode ? <FolderIcon isOpen={isExpanded} /> : <CubeIcon />}
</div>
<RenameInput value={itemName} onRename={() => {}} canEdit={true} /> <RenameInput value={itemName} onRename={() => {}} canEdit={true} />
<div className="node-controls"> <div className="node-controls">
<button <button className="control-button" title={isVisible ? "Visible" : "Hidden"} onClick={(e) => handleOptionClick(e, "visibility")}>
className="control-button"
title={isVisible ? "Visible" : "Hidden"}
onClick={(e) => {
handleOptionClick(e, "visibility");
}}
>
<EyeIcon isClosed={!isVisible} /> <EyeIcon isClosed={!isVisible} />
</button> </button>
<button {isGroupNode && item.children && item.children.length > 0 && (
className="control-button" <button className="control-button" title="Focus" onClick={(e) => handleOptionClick(e, "focus")}>
title={isLocked ? "Locked" : "Unlocked"}
onClick={(e) => {
handleOptionClick(e, "focus");
}}
>
<FocusIcon /> <FocusIcon />
</button> </button>
<button )}
className="control-button" <button className="control-button" title={isLocked ? "Locked" : "Unlocked"} onClick={(e) => handleOptionClick(e, "lock")}>
title={isLocked ? "Locked" : "Unlocked"}
onClick={(e) => {
handleOptionClick(e, "lock");
}}
>
<LockIcon isLocked={isLocked} /> <LockIcon isLocked={isLocked} />
</button> </button>
{isGroupNode && ( {isGroupNode && (
<button <button className="control-button" onClick={(e) => handleOptionClick(e, "kebab")}>
className="control-button"
onClick={(e) => {
handleOptionClick(e, "kebab");
}}
>
<KebebIcon /> <KebebIcon />
</button> </button>
)} )}
@@ -200,14 +159,7 @@ const TreeNode = ({
</div> </div>
{isGroupNode && isExpanded && item.children && ( {isGroupNode && isExpanded && item.children && (
<div <div className="tree-children" style={{ "--left": level } as React.CSSProperties}>
className="tree-children"
style={
{
"--left": level,
} as React.CSSProperties
}
>
{item.children.map((child) => ( {item.children.map((child) => (
<TreeNode <TreeNode
key={isGroup(child) ? child.groupUuid : child.modelUuid} key={isGroup(child) ? child.groupUuid : child.modelUuid}
@@ -230,9 +182,8 @@ const TreeNode = ({
}; };
// Main Component // Main Component
export const Outline = () => { export const AssetOutline = () => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const { setContextAction } = useContextActionStore();
const lastSelectedRef = useRef<{ item: AssetGroupChild; index: number } | null>(null); const lastSelectedRef = useRef<{ item: AssetGroupChild; index: number } | null>(null);
const dragStateRef = useRef<DragState>({ const dragStateRef = useRef<DragState>({
draggedItem: null, draggedItem: null,
@@ -242,15 +193,7 @@ export const Outline = () => {
}); });
const [_, forceUpdate] = useState({}); const [_, forceUpdate] = useState({});
const { scene, assetGroupStore, assetStore, versionStore, undoRedo3DStore } = useSceneContext(); const { scene, assetGroupStore, assetStore, versionStore, undoRedo3DStore } = useSceneContext();
const { const { addSelectedAsset, clearSelectedAssets, getAssetById, peekToggleVisibility, peekToggleLock, toggleSelectedAsset, selectedAssets, setSelectedAssets } = assetStore();
addSelectedAsset,
clearSelectedAssets,
getAssetById,
peekToggleVisibility,
peekToggleLock,
toggleSelectedAsset,
selectedAssets,
} = assetStore();
const { const {
groupHierarchy, groupHierarchy,
isGroup, isGroup,
@@ -260,6 +203,12 @@ export const Outline = () => {
addChildToGroup, addChildToGroup,
removeChildFromGroup, removeChildFromGroup,
getGroupsContainingGroup, getGroupsContainingGroup,
selectedGroups,
addSelectedGroup,
removeSelectedGroup,
toggleSelectedGroup,
clearSelectedGroups,
hasSelectedGroup,
} = assetGroupStore(); } = assetGroupStore();
const { projectId } = useParams(); const { projectId } = useParams();
const { push3D } = undoRedo3DStore(); const { push3D } = undoRedo3DStore();
@@ -267,6 +216,7 @@ export const Outline = () => {
const { userId, organization } = getUserData(); const { userId, organization } = getUserData();
const { selectedVersion } = versionStore(); const { selectedVersion } = versionStore();
const { updateAssetInScene } = useAssetResponseHandler(); const { updateAssetInScene } = useAssetResponseHandler();
const { zoomMeshes } = useZoomMesh();
const getFlattenedHierarchy = useCallback((): AssetGroupChild[] => { const getFlattenedHierarchy = useCallback((): AssetGroupChild[] => {
const flattened: AssetGroupChild[] = []; const flattened: AssetGroupChild[] = [];
@@ -304,11 +254,7 @@ export const Outline = () => {
}) })
.then((data) => { .then((data) => {
if (!data.message || !data.data) { if (!data.message || !data.data) {
echo.error( echo.error(`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`);
`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${
asset.modelName
}`
);
return; return;
} }
if (data.message === "Model updated successfully" && data.data) { if (data.message === "Model updated successfully" && data.data) {
@@ -327,22 +273,14 @@ export const Outline = () => {
}; };
updateAssetInScene(model, () => { updateAssetInScene(model, () => {
echo.info( echo.info(`${asset.isVisible ? "Hid" : "Unhid"} asset: ${model.modelName}`);
`${asset.isVisible ? "Hid" : "Unhid"} asset: ${model.modelName}`
);
}); });
} else { } else {
echo.error( echo.error(`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`);
`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${
asset.modelName
}`
);
} }
}) })
.catch(() => { .catch(() => {
echo.error( echo.error(`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`);
`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`
);
}); });
} else { } else {
const data = { const data = {
@@ -387,11 +325,7 @@ export const Outline = () => {
}) })
.then((data) => { .then((data) => {
if (!data.message || !data.data) { if (!data.message || !data.data) {
echo.error( echo.error(`Error ${asset.isVisible ? "locking" : "unlocking"} asset: ${asset.modelName}`);
`Error ${asset.isVisible ? "locking" : "unlocking"} asset: ${
asset.modelName
}`
);
return; return;
} }
if (data.message === "Model updated successfully" && data.data) { if (data.message === "Model updated successfully" && data.data) {
@@ -410,26 +344,14 @@ export const Outline = () => {
}; };
updateAssetInScene(model, () => { updateAssetInScene(model, () => {
echo.info( echo.info(`${asset.isVisible ? "Locked" : "Unlocked"} asset: ${model.modelName}`);
`${asset.isVisible ? "Locked" : "Unlocked"} asset: ${
model.modelName
}`
);
}); });
} else { } else {
echo.error( echo.error(`Error ${asset.isVisible ? "locking" : "unlocking"} asset: ${asset.modelName}`);
`Error ${asset.isVisible ? "locking" : "unlocking"} asset: ${
asset.modelName
}`
);
} }
}) })
.catch(() => { .catch(() => {
echo.error( echo.error(`Error ${asset.isVisible ? "locking" : "unlocking"} asset: ${asset.modelName}`);
`Error ${asset.isVisible ? "locking" : "unlocking"} asset: ${
asset.modelName
}`
);
}); });
} else { } else {
const data = { const data = {
@@ -461,16 +383,13 @@ export const Outline = () => {
[setGroupExpanded] [setGroupExpanded]
); );
const handleDragStart = useCallback( const handleDragStart = useCallback((e: DragEvent, item: AssetGroupChild, parentGroupUuid: string | null) => {
(e: DragEvent, item: AssetGroupChild, parentGroupUuid: string | null) => {
dragStateRef.current.draggedItem = item; dragStateRef.current.draggedItem = item;
dragStateRef.current.draggedItemParentGroupUuid = parentGroupUuid; dragStateRef.current.draggedItemParentGroupUuid = parentGroupUuid;
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
forceUpdate({}); forceUpdate({});
}, }, []);
[]
);
const handleDragOver = useCallback( const handleDragOver = useCallback(
(e: DragEvent, targetItem: AssetGroupChild) => { (e: DragEvent, targetItem: AssetGroupChild) => {
@@ -540,10 +459,7 @@ export const Outline = () => {
} }
// Update target group // Update target group
if ( if (dragStateRef.current.targetGroupUuid !== targetGroupUuid || dragStateRef.current.isRootTarget !== false) {
dragStateRef.current.targetGroupUuid !== targetGroupUuid ||
dragStateRef.current.isRootTarget !== false
) {
dragStateRef.current.targetGroupUuid = targetGroupUuid; dragStateRef.current.targetGroupUuid = targetGroupUuid;
dragStateRef.current.isRootTarget = false; dragStateRef.current.isRootTarget = false;
forceUpdate({}); forceUpdate({});
@@ -638,7 +554,7 @@ export const Outline = () => {
(e: React.MouseEvent, item: AssetGroupChild) => { (e: React.MouseEvent, item: AssetGroupChild) => {
e.preventDefault(); e.preventDefault();
const isShiftClick = e.shiftKey; const isShiftClick = e.shiftKey;
const isCtrlClick = e.ctrlKey; const isCtrlClick = e.ctrlKey || e.metaKey;
if (!scene.current) return; if (!scene.current) return;
@@ -647,51 +563,55 @@ export const Outline = () => {
return isGroup(i) ? i.groupUuid : i.modelUuid; return isGroup(i) ? i.groupUuid : i.modelUuid;
}; };
const itemId = getItemId(item);
const flattened = getFlattenedHierarchy();
const clickedIndex = flattened.findIndex((flatItem) => getItemId(flatItem) === itemId);
// Single click (no modifiers) - select only this item // Single click (no modifiers) - select only this item
if (!isShiftClick && !isCtrlClick) { if (!isShiftClick && !isCtrlClick) {
if (!isGroup(item)) { if (isGroup(item)) {
clearSelectedAssets();
clearSelectedGroups();
addSelectedGroup(item.groupUuid);
lastSelectedRef.current = { item, index: clickedIndex };
} else {
clearSelectedGroups();
clearSelectedAssets();
const asset = scene.current.getObjectByProperty("uuid", item.modelUuid); const asset = scene.current.getObjectByProperty("uuid", item.modelUuid);
if (asset) { if (asset) {
clearSelectedAssets();
addSelectedAsset(asset); addSelectedAsset(asset);
lastSelectedRef.current = { item, index: clickedIndex };
}
}
}
// Update last selected reference
const flattened = getFlattenedHierarchy();
const index = flattened.findIndex(
(flatItem) => getItemId(flatItem) === getItemId(item)
);
lastSelectedRef.current = { item, index };
}
}
}
// Ctrl+Click - toggle selection // Ctrl+Click - toggle selection
else if (isCtrlClick && !isShiftClick) { else if (isCtrlClick && !isShiftClick) {
if (!isGroup(item)) { if (isGroup(item)) {
toggleSelectedGroup(item.groupUuid);
lastSelectedRef.current = { item, index: clickedIndex };
} else {
const asset = scene.current.getObjectByProperty("uuid", item.modelUuid); const asset = scene.current.getObjectByProperty("uuid", item.modelUuid);
if (asset) { if (asset) {
toggleSelectedAsset(asset); toggleSelectedAsset(asset);
lastSelectedRef.current = { item, index: clickedIndex };
// Update last selected reference
const flattened = getFlattenedHierarchy();
const index = flattened.findIndex(
(flatItem) => getItemId(flatItem) === getItemId(item)
);
lastSelectedRef.current = { item, index };
} }
} }
} }
// Shift+Click - range selection // Shift+Click - range selection
else if (isShiftClick) { else if (isShiftClick) {
const flattened = getFlattenedHierarchy();
const clickedIndex = flattened.findIndex(
(flatItem) => getItemId(flatItem) === getItemId(item)
);
if (clickedIndex === -1) return; if (clickedIndex === -1) return;
// If no last selected item, treat as normal selection // If no last selected item, treat as normal selection
if (!lastSelectedRef.current) { if (!lastSelectedRef.current) {
if (!isGroup(item)) { if (isGroup(item)) {
clearSelectedAssets();
clearSelectedGroups();
addSelectedGroup(item.groupUuid);
lastSelectedRef.current = { item, index: clickedIndex };
} else {
clearSelectedGroups();
clearSelectedAssets();
const asset = scene.current.getObjectByProperty("uuid", item.modelUuid); const asset = scene.current.getObjectByProperty("uuid", item.modelUuid);
if (asset) { if (asset) {
clearSelectedAssets(); clearSelectedAssets();
@@ -709,20 +629,29 @@ export const Outline = () => {
// Get all items in range // Get all items in range
const itemsInRange = flattened.slice(startIndex, endIndex + 1); const itemsInRange = flattened.slice(startIndex, endIndex + 1);
// Filter out groups, only select assets const groupsInRange: AssetGroupHierarchyNode[] = [];
const assetsInRange: Asset[] = []; const assetsInRange: Asset[] = [];
itemsInRange.forEach((rangeItem) => { itemsInRange.forEach((rangeItem) => {
if (!isGroup(rangeItem)) { if (isGroup(rangeItem)) {
groupsInRange.push(rangeItem);
} else {
assetsInRange.push(rangeItem); assetsInRange.push(rangeItem);
} }
}); });
// If Ctrl+Shift, add to existing selection; otherwise replace // If Ctrl+Shift, add to existing selection; otherwise replace
if (!isCtrlClick) { if (!isCtrlClick) {
clearSelectedGroups();
clearSelectedAssets(); clearSelectedAssets();
} }
// Add all assets in range to selection groupsInRange.forEach((group) => {
if (!hasSelectedGroup(group.groupUuid)) {
addSelectedGroup(group.groupUuid);
}
});
assetsInRange.forEach((assetItem) => { assetsInRange.forEach((assetItem) => {
const asset = scene.current!.getObjectByProperty("uuid", assetItem.modelUuid); const asset = scene.current!.getObjectByProperty("uuid", assetItem.modelUuid);
if (asset) { if (asset) {
@@ -738,11 +667,21 @@ export const Outline = () => {
addSelectedAsset, addSelectedAsset,
getFlattenedHierarchy, getFlattenedHierarchy,
toggleSelectedAsset, toggleSelectedAsset,
toggleSelectedGroup,
clearSelectedGroups,
addSelectedGroup,
removeSelectedGroup,
selectedGroups,
selectedAssets,
] ]
); );
const handleOptionClick = useCallback( const handleOptionClick = useCallback(
(option: string, item: AssetGroupChild) => { (option: string, item: AssetGroupChild) => {
const getItemId = (i: AssetGroupChild): string => {
return isGroup(i) ? i.groupUuid : i.modelUuid;
};
if (option === "visibility") { if (option === "visibility") {
if (isGroup(item)) { if (isGroup(item)) {
} else { } else {
@@ -830,7 +769,17 @@ export const Outline = () => {
} else if (option === "focus") { } else if (option === "focus") {
if (isGroup(item)) { if (isGroup(item)) {
} else { } else {
setContextAction("focusAsset"); clearSelectedGroups();
clearSelectedAssets();
const asset = scene.current?.getObjectByProperty("uuid", item.modelUuid);
if (asset) {
const itemId = getItemId(item);
const flattened = getFlattenedHierarchy();
const clickedIndex = flattened.findIndex((flatItem) => getItemId(flatItem) === itemId);
addSelectedAsset(asset);
lastSelectedRef.current = { item, index: clickedIndex };
zoomMeshes([itemId]);
}
} }
} else if (option === "kebab") { } else if (option === "kebab") {
if (isGroup(item)) { if (isGroup(item)) {
@@ -841,6 +790,30 @@ export const Outline = () => {
[selectedVersion, builderSocket, projectId, userId, organization] [selectedVersion, builderSocket, projectId, userId, organization]
); );
const handleAddGroup = useCallback(() => {}, [assetGroupStore, clearSelectedGroups, addSelectedGroup]);
const handleExpandAll = useCallback(() => {}, [assetGroupStore, setGroupExpanded]);
// Selection statistics
const selectionStats = useMemo(() => {
const totalSelectedAssets = selectedAssets.length;
const totalSelectedGroups = selectedGroups.length;
if (totalSelectedGroups > 0 && totalSelectedAssets > 0) {
return `${totalSelectedGroups} group${totalSelectedGroups > 1 ? "s" : ""} and ${totalSelectedAssets} asset${totalSelectedAssets > 1 ? "s" : ""} selected`;
} else if (totalSelectedGroups > 0) {
return `${totalSelectedGroups} group${totalSelectedGroups > 1 ? "s" : ""} selected`;
} else if (totalSelectedAssets > 0) {
return `${totalSelectedAssets} asset${totalSelectedAssets > 1 ? "s" : ""} selected`;
} else {
return `${groupHierarchy.length} root items`;
}
}, [selectedAssets.length, selectedGroups.length, groupHierarchy.length]);
useOuterClick(() => {
clearSelectedGroups();
}, ["tree-node"]);
return ( return (
<> <>
<div className="outline-overlay" onDragEnd={handleDragEnd}> <div className="outline-overlay" onDragEnd={handleDragEnd}>
@@ -850,39 +823,20 @@ export const Outline = () => {
<p>Assets</p> <p>Assets</p>
</div> </div>
<div className="outline-toolbar"> <div className="outline-toolbar">
<button className="toolbar-button" title="Add Group"> <button className="toolbar-button" title="Add Group" onClick={handleAddGroup}>
<AddIcon /> <AddIcon />
</button> </button>
{/* <button className="toolbar-button" title="Delete"> <button className="toolbar-button" title="Expand All" onClick={handleExpandAll}>
<DeleteIcon />
</button> */}
<button
className="toolbar-button"
title="Expand All"
onClick={() => {
const { assetGroups, setGroupExpanded } =
assetGroupStore.getState();
assetGroups.forEach((group) =>
setGroupExpanded(group.groupUuid, true)
);
}}
>
<CollapseAllIcon /> <CollapseAllIcon />
</button> </button>
<button className="close-button" onClick={() => setIsOpen(!isOpen)}> <button className="close-button" onClick={() => setIsOpen(!isOpen)}>
<ChevronIcon isOpen /> <ChevronIcon isOpen={isOpen} />
</button> </button>
</div> </div>
</div> </div>
{isOpen && ( {isOpen && (
<div <div className={`outline-content ${dragStateRef.current.isRootTarget ? "root-drop-target" : ""}`} onDragOver={handleRootDragOver} onDrop={handleDrop}>
className={`outline-content ${
dragStateRef.current.isRootTarget ? "root-drop-target" : ""
}`}
onDragOver={handleRootDragOver}
onDrop={handleDrop}
>
{groupHierarchy.map((item) => ( {groupHierarchy.map((item) => (
<TreeNode <TreeNode
key={isGroup(item) ? item.groupUuid : item.modelUuid} key={isGroup(item) ? item.groupUuid : item.modelUuid}
@@ -902,13 +856,7 @@ export const Outline = () => {
</div> </div>
</div> </div>
<div className="outline-footer"> <div className="outline-footer">
<span <span className={`footer-stats ${selectedAssets.length + selectedGroups.length > 1 ? "multi-selection" : ""}`}>{selectionStats}</span>
className={`footer-stats ${selectedAssets.length > 1 ? "multi-selection" : ""}`}
>
{selectedAssets.length > 1
? `${selectedAssets.length} items selected`
: `${groupHierarchy.length} root items`}
</span>
</div> </div>
</> </>
); );

View File

@@ -11,14 +11,15 @@ import useCallBackOnKey from "../../../../utils/hooks/useCallBackOnKey";
import generateUniqueAssetGroupName from "../../../builder/asset/functions/generateUniqueAssetGroupName"; import generateUniqueAssetGroupName from "../../../builder/asset/functions/generateUniqueAssetGroupName";
import { setAssetsApi } from "../../../../services/factoryBuilder/asset/floorAsset/setAssetsApi"; import { setAssetsApi } from "../../../../services/factoryBuilder/asset/floorAsset/setAssetsApi";
import { getAssetGroupsApi } from "../../../../services/factoryBuilder/group/assetGroup/getAssetGroupsApi"; import { getAssetGroupsApi } from "../../../../services/factoryBuilder/group/assetGroup/getAssetGroupsApi";
import { createAssetGroupApi } from "../../../../services/factoryBuilder/group/assetGroup/createAssetGroupApi";
function GroupControls() { function GroupControls() {
const { projectId } = useParams(); const { projectId } = useParams();
const { builderSocket } = useSocketStore(); const { builderSocket } = useSocketStore();
const { assetStore, undoRedo3DStore, versionStore, assetGroupStore } = useSceneContext(); const { assetStore, undoRedo3DStore, versionStore, assetGroupStore } = useSceneContext();
const { assetGroups, addGroup, buildHierarchy } = assetGroupStore(); const { assetGroups, addGroup, setGroups, buildHierarchy } = assetGroupStore();
const { push3D } = undoRedo3DStore(); const { push3D } = undoRedo3DStore();
const { assets, selectedAssets, pastedObjects, duplicatedObjects, movedObjects, rotatedObjects } = assetStore(); const { assets, selectedAssets, pastedObjects, duplicatedObjects, movedObjects, rotatedObjects, getSelectedAssetUuids } = assetStore();
const { updateAssetInScene, removeAssetFromScene } = useAssetResponseHandler(); const { updateAssetInScene, removeAssetFromScene } = useAssetResponseHandler();
const { contextAction, setContextAction } = useContextActionStore(); const { contextAction, setContextAction } = useContextActionStore();
const { selectedVersion } = versionStore(); const { selectedVersion } = versionStore();
@@ -28,7 +29,9 @@ function GroupControls() {
if (!projectId || !selectedVersion) return; if (!projectId || !selectedVersion) return;
getAssetGroupsApi(projectId, selectedVersion.versionId).then((data) => { getAssetGroupsApi(projectId, selectedVersion.versionId).then((data) => {
console.log("data: ", data); if (data && data.length > 0) {
setGroups(data);
}
}); });
}, [projectId, selectedVersion]); }, [projectId, selectedVersion]);
@@ -43,18 +46,12 @@ function GroupControls() {
setContextAction(null); setContextAction(null);
groupSelection(); groupSelection();
} }
}, [contextAction]); }, [contextAction, projectId, selectedVersion]);
const groupSelection = () => { const groupSelection = () => {
const assetUuids: string[] = []; const assetUuids: string[] = getSelectedAssetUuids();
selectedAssets.forEach((selectedAsset) => { if (assetUuids.length > 0 && projectId && selectedVersion) {
if (selectedAsset.userData.modelUuid) {
assetUuids.push(selectedAsset.userData.modelUuid);
}
});
if (assetUuids.length > 0) {
const groupName = generateUniqueAssetGroupName({ baseName: "Group", existingGroups: assetGroups }); const groupName = generateUniqueAssetGroupName({ baseName: "Group", existingGroups: assetGroups });
const assetGroup: AssetGroup = { const assetGroup: AssetGroup = {
@@ -63,10 +60,23 @@ function GroupControls() {
isVisible: true, isVisible: true,
isLocked: false, isLocked: false,
isExpanded: true, isExpanded: true,
childrens: assetUuids.map((assetUuid) => { children: assetUuids.map((assetUuid) => {
return { type: "Asset", childrenUuid: assetUuid }; return { type: "Asset", childrenUuid: assetUuid };
}), }),
}; };
// createAssetGroupApi({
// projectId,
// versionId: selectedVersion.versionId,
// groupUuid: assetGroup.groupUuid,
// groupName: assetGroup.groupName,
// isVisible: assetGroup.isVisible,
// isExpanded: assetGroup.isExpanded,
// isLocked: assetGroup.isLocked,
// children: assetGroup.children,
// }).then((data) => {
// console.log("data: ", data);
// });
addGroup(assetGroup); addGroup(assetGroup);
} }
}; };
@@ -81,6 +91,8 @@ function GroupControls() {
{ dependencies: [pastedObjects, duplicatedObjects, movedObjects, rotatedObjects, selectedAssets, selectedVersion, builderSocket, projectId, userId, organization], noRepeat: true } { dependencies: [pastedObjects, duplicatedObjects, movedObjects, rotatedObjects, selectedAssets, selectedVersion, builderSocket, projectId, userId, organization], noRepeat: true }
); );
useCallBackOnKey(() => {}, "Alt+G", { dependencies: [], noRepeat: true });
return null; return null;
} }

View File

@@ -1,37 +1,44 @@
const url_Backend_dwinzo = `http://${process.env.REACT_APP_SERVER_REST_API_BASE_URL}`; const url_Backend_dwinzo = `http://${process.env.REACT_APP_SERVER_REST_API_BASE_URL}`;
interface Props { export const createAssetGroupApi = async ({
projectId,
versionId,
groupUuid,
groupName,
isVisible,
isExpanded,
isLocked,
children,
}: {
projectId: string; projectId: string;
versionId: string; versionId: string;
groupUuid: string; groupUuid: string;
groupName: string; groupName: string;
isVisible: string; isVisible: boolean;
isExpanded: string; isExpanded: boolean;
isLocked: string; isLocked: boolean;
childrens: { children: {
type: "Asset" | "Group"; type: "Asset" | "Group";
childrenUuid: string; childrenUuid: string;
}[]; }[];
} }) => {
export const createAssetGroupApi = async (props: Props) => {
try { try {
const response = await fetch(`${url_Backend_dwinzo}/api/V1/assetgroup/${props.projectId}/${props.versionId}`, { const response = await fetch(`${url_Backend_dwinzo}/api/V1/assetgroup/${projectId}/${versionId}`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token") || ""}`, Authorization: `Bearer ${localStorage.getItem("token") || ""}`,
token: localStorage.getItem("token") || "", token: localStorage.getItem("token") || "",
refresh_token: localStorage.getItem("refreshToken") || "", refresh_token: localStorage.getItem("refreshToken") || "",
body: JSON.stringify({
groupUuid: props.groupUuid,
groupName: props.groupName,
isVisible: props.isVisible,
isExpanded: props.isExpanded,
isLocked: props.isLocked,
childrens: props.childrens,
}),
}, },
body: JSON.stringify({
groupUuid: groupUuid,
groupName: groupName,
isVisible: isVisible,
isExpanded: isExpanded,
isLocked: isLocked,
children: children,
}),
}); });
const newAccessToken = response.headers.get("x-access-token"); const newAccessToken = response.headers.get("x-access-token");

View File

@@ -2,7 +2,7 @@ let url_Backend_dwinzo = `http://${process.env.REACT_APP_SERVER_REST_API_BASE_UR
export const getAssetGroupsApi = async (projectId: string, versionId: string) => { export const getAssetGroupsApi = async (projectId: string, versionId: string) => {
try { try {
const response = await fetch(`${url_Backend_dwinzo}/api/V1/assetGroups/${projectId}/${versionId}`, { const response = await fetch(`${url_Backend_dwinzo}/api/V1/assetgroups/${projectId}/${versionId}`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: "Bearer <access_token>", Authorization: "Bearer <access_token>",

View File

@@ -43,6 +43,7 @@ interface AssetGroupStore {
getGroupById: (groupUuid: string) => AssetGroup | undefined; getGroupById: (groupUuid: string) => AssetGroup | undefined;
getGroupsContainingAsset: (assetUuid: string) => AssetGroup[]; getGroupsContainingAsset: (assetUuid: string) => AssetGroup[];
getGroupsContainingGroup: (childGroupUuid: string) => AssetGroup[]; getGroupsContainingGroup: (childGroupUuid: string) => AssetGroup[];
getParentGroup: (item: AssetGroupChild) => string | null;
hasGroup: (groupUuid: string) => boolean; hasGroup: (groupUuid: string) => boolean;
isGroup: (item: AssetGroupChild) => item is AssetGroupHierarchyNode; isGroup: (item: AssetGroupChild) => item is AssetGroupHierarchyNode;
isEmptyGroup: (groupUuid: string) => boolean; isEmptyGroup: (groupUuid: string) => boolean;
@@ -69,18 +70,18 @@ export const createAssetGroupStore = () => {
} }
// Find all asset children in the new group // Find all asset children in the new group
const assetChildren = group.childrens.filter((child) => child.type === "Asset"); const assetChildren = group.children.filter((child) => child.type === "Asset");
const assetUuids = new Set(assetChildren.map((child) => child.childrenUuid)); const assetUuids = new Set(assetChildren.map((child) => child.childrenUuid));
// Remove these assets from existing groups and track updated groups // Remove these assets from existing groups and track updated groups
const updatedGroups: AssetGroup[] = []; const updatedGroups: AssetGroup[] = [];
state.assetGroups.forEach((existingGroup) => { state.assetGroups.forEach((existingGroup) => {
const originalLength = existingGroup.childrens.length; const originalLength = existingGroup.children.length;
existingGroup.childrens = existingGroup.childrens.filter((child) => !(child.type === "Asset" && assetUuids.has(child.childrenUuid))); existingGroup.children = existingGroup.children.filter((child) => !(child.type === "Asset" && assetUuids.has(child.childrenUuid)));
// If group was modified, add to updated groups // If group was modified, add to updated groups
if (existingGroup.childrens.length !== originalLength) { if (existingGroup.children.length !== originalLength) {
updatedGroups.push({ ...existingGroup }); updatedGroups.push({ ...existingGroup });
} }
}); });
@@ -98,7 +99,7 @@ export const createAssetGroupStore = () => {
set((state) => { set((state) => {
// First remove this group from any parent groups // First remove this group from any parent groups
state.assetGroups.forEach((group) => { state.assetGroups.forEach((group) => {
group.childrens = group.childrens.filter((child) => !(child.type === "Group" && child.childrenUuid === groupUuid)); group.children = group.children.filter((child) => !(child.type === "Group" && child.childrenUuid === groupUuid));
}); });
// Then remove the group itself // Then remove the group itself
@@ -179,17 +180,17 @@ export const createAssetGroupStore = () => {
state.assetGroups.forEach((group) => { state.assetGroups.forEach((group) => {
if (group.groupUuid === groupUuid) return; // skip target group if (group.groupUuid === groupUuid) return; // skip target group
const originalLength = group.childrens.length; const originalLength = group.children.length;
group.childrens = group.childrens.filter((c) => c.childrenUuid !== child.childrenUuid); group.children = group.children.filter((c) => c.childrenUuid !== child.childrenUuid);
if (group.childrens.length !== originalLength) { if (group.children.length !== originalLength) {
updatedGroups.push({ ...group }); updatedGroups.push({ ...group });
} }
}); });
// 2⃣ Add the child to the target group (if not already present) // 2⃣ Add the child to the target group (if not already present)
if (!targetGroup.childrens.some((c) => c.childrenUuid === child.childrenUuid)) { if (!targetGroup.children.some((c) => c.childrenUuid === child.childrenUuid)) {
targetGroup.childrens.push(child); targetGroup.children.push(child);
updatedGroups.push({ ...targetGroup }); updatedGroups.push({ ...targetGroup });
} }
@@ -206,7 +207,7 @@ export const createAssetGroupStore = () => {
set((state) => { set((state) => {
const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); const group = state.assetGroups.find((g) => g.groupUuid === groupUuid);
if (group) { if (group) {
group.childrens = group.childrens.filter((child) => child.childrenUuid !== childUuid); group.children = group.children.filter((child) => child.childrenUuid !== childUuid);
state.groupHierarchy = get().buildHierarchy([], state.assetGroups); state.groupHierarchy = get().buildHierarchy([], state.assetGroups);
} }
}); });
@@ -214,7 +215,7 @@ export const createAssetGroupStore = () => {
getGroupChildren: (groupUuid) => { getGroupChildren: (groupUuid) => {
const group = get().assetGroups.find((g) => g.groupUuid === groupUuid); const group = get().assetGroups.find((g) => g.groupUuid === groupUuid);
return group?.childrens || []; return group?.children || [];
}, },
// Group properties // Group properties
@@ -271,7 +272,7 @@ export const createAssetGroupStore = () => {
const buildNode = (group: AssetGroup): AssetGroupHierarchyNode => { const buildNode = (group: AssetGroup): AssetGroupHierarchyNode => {
const children: AssetGroupChild[] = []; const children: AssetGroupChild[] = [];
group.childrens.forEach((child) => { group.children.forEach((child) => {
if (child.type === "Asset") { if (child.type === "Asset") {
const asset = assetMap.get(child.childrenUuid); const asset = assetMap.get(child.childrenUuid);
if (asset) { if (asset) {
@@ -300,7 +301,7 @@ export const createAssetGroupStore = () => {
// Find root groups (groups that are not children of any other group) // Find root groups (groups that are not children of any other group)
const childGroupUuids = new Set(); const childGroupUuids = new Set();
groups.forEach((group) => { groups.forEach((group) => {
group.childrens.forEach((child) => { group.children.forEach((child) => {
if (child.type === "Group") { if (child.type === "Group") {
childGroupUuids.add(child.childrenUuid); childGroupUuids.add(child.childrenUuid);
} }
@@ -345,7 +346,7 @@ export const createAssetGroupStore = () => {
isVisible: node.isVisible, isVisible: node.isVisible,
isLocked: node.isLocked, isLocked: node.isLocked,
isExpanded: node.isExpanded, isExpanded: node.isExpanded,
childrens: node.children.map((child) => children: node.children.map((child) =>
"modelUuid" in child ? { type: "Asset" as const, childrenUuid: child.modelUuid } : { type: "Group" as const, childrenUuid: child.groupUuid } "modelUuid" in child ? { type: "Asset" as const, childrenUuid: child.modelUuid } : { type: "Group" as const, childrenUuid: child.groupUuid }
), ),
}); });
@@ -413,13 +414,13 @@ export const createAssetGroupStore = () => {
if (child.type === "Group") { if (child.type === "Group") {
const childGroup = get().assetGroups.find((g) => g.groupUuid === child.childrenUuid); const childGroup = get().assetGroups.find((g) => g.groupUuid === child.childrenUuid);
if (childGroup) { if (childGroup) {
collectChildren(childGroup.childrens); collectChildren(childGroup.children);
} }
} }
}); });
}; };
collectChildren(group.childrens); collectChildren(group.children);
return allChildren; return allChildren;
}, },
@@ -429,11 +430,21 @@ export const createAssetGroupStore = () => {
}, },
getGroupsContainingAsset: (assetUuid) => { getGroupsContainingAsset: (assetUuid) => {
return get().assetGroups.filter((group) => group.childrens.some((child) => child.type === "Asset" && child.childrenUuid === assetUuid)); return get().assetGroups.filter((group) => group.children.some((child) => child.type === "Asset" && child.childrenUuid === assetUuid));
}, },
getGroupsContainingGroup: (childGroupUuid) => { getGroupsContainingGroup: (childGroupUuid) => {
return get().assetGroups.filter((group) => group.childrens.some((child) => child.type === "Group" && child.childrenUuid === childGroupUuid)); return get().assetGroups.filter((group) => group.children.some((child) => child.type === "Group" && child.childrenUuid === childGroupUuid));
},
getParentGroup: (item) => {
if (get().isGroup(item)) {
const parents = get().getGroupsContainingGroup(item.groupUuid);
return parents.length > 0 ? parents[0].groupUuid : null;
} else {
const parents = get().getGroupsContainingAsset(item.modelUuid);
return parents.length > 0 ? parents[0].groupUuid : null;
}
}, },
hasGroup: (groupUuid) => { hasGroup: (groupUuid) => {
@@ -446,7 +457,7 @@ export const createAssetGroupStore = () => {
isEmptyGroup: (groupUuid) => { isEmptyGroup: (groupUuid) => {
const group = get().assetGroups.find((g) => g.groupUuid === groupUuid); const group = get().assetGroups.find((g) => g.groupUuid === groupUuid);
return !group || group.childrens.length === 0; return !group || group.children.length === 0;
}, },
})) }))
); );

View File

@@ -85,6 +85,7 @@
overflow-y: auto; overflow-y: auto;
border-radius: $border-radius-medium; border-radius: $border-radius-medium;
position: relative; position: relative;
padding: 2px;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -107,8 +108,7 @@
&.root-drop-target { &.root-drop-target {
background: var(--background-color-selected); background: var(--background-color-selected);
box-shadow: inset 0 0 0 2px var(--border-color-accent), box-shadow: inset 0 0 0 2px var(--border-color-accent), inset 0 0 20px var(--highlight-accent-color);
inset 0 0 20px var(--highlight-accent-color);
border-radius: $border-radius-medium; border-radius: $border-radius-medium;
&::before { &::before {
@@ -134,29 +134,42 @@
.tree-node { .tree-node {
position: relative; position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: 2px solid transparent;
&.drop-target-highlight { &.drop-target-highlight {
background: var(--background-color-selected); background: var(--background-color-selected);
border-radius: $border-radius-medium; border-radius: $border-radius-extra-large;
box-shadow: inset 0 0 0 2px var(--border-color-accent), outline: 2px solid var(--border-color-accent);
0 0 20px var(--highlight-accent-color), 0 4px 12px var(--background-color-selected); z-index: 1;
}
&.group-selected {
background: var(--background-color-selected);
border-radius: $border-radius-extra-large;
} }
.tree-node-content { .tree-node-content {
@include flex-center; @include flex-center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 4px 12px;
border-radius: $border-radius-medium; border-radius: $border-radius-extra-large;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent; background: transparent;
position: relative; position: relative;
outline: 1px solid transparent;
outline-offset: -1px;
&:hover { &:hover {
background: var(--background-color-accent); // background: var(--background-color-secondary);
outline: 1px solid var(--accent-color);
} }
&.selected { &.selected {
background: var(--background-color-selected); span {
color: var(--text-button-color);
}
background: var(--background-color-accent);
outline: 1px solid transparent;
} }
&.dragging { &.dragging {
@@ -264,11 +277,8 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 1px; width: 1px;
background: linear-gradient( background: linear-gradient(to bottom, var(--accent-color), var(--background-color-selected));
to bottom, z-index: 100;
var(--accent-color),
var(--background-color-selected)
);
box-shadow: 0 0 4px var(--highlight-accent-color); box-shadow: 0 0 4px var(--highlight-accent-color);
} }
} }

View File

@@ -56,7 +56,7 @@ interface AssetGroup {
isVisible: boolean; isVisible: boolean;
isLocked: boolean; isLocked: boolean;
isExpanded: boolean; isExpanded: boolean;
childrens: { children: {
type: "Asset" | "Group"; type: "Asset" | "Group";
childrenUuid: string; childrenUuid: string;
}[]; }[];

View File

@@ -0,0 +1,66 @@
import { useEffect, useRef } from "react";
export function useOuterClick(
callback: () => void,
contextClassNames: string[],
enabled: boolean = true,
ignoreIfMoved: boolean = true,
clickType: "left" | "right" = "left",
dependencies: any[] = []
) {
const callbackRef = useRef(callback);
const classNamesRef = useRef(contextClassNames);
const mouseDownTargetRef = useRef<EventTarget | null>(null);
const movedRef = useRef(false);
// Keep refs updated
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
classNamesRef.current = contextClassNames;
}, [contextClassNames]);
useEffect(() => {
if (!enabled) return;
const handleMouseDown = (event: MouseEvent) => {
mouseDownTargetRef.current = event.target;
movedRef.current = false;
};
const handleMouseMove = () => {
movedRef.current = true;
};
const handleMouseUp = (event: MouseEvent) => {
const downTarget = mouseDownTargetRef.current as HTMLElement | null;
const upTarget = event.target as HTMLElement;
if (!downTarget || !upTarget) return;
// Check click type
const isCorrectClick = (clickType === "left" && event.button === 0) || (clickType === "right" && event.button === 2);
if (!isCorrectClick) return;
if (ignoreIfMoved && movedRef.current) return;
// Check if click is inside any ignored context
const isInside = classNamesRef.current.some((className) => downTarget.closest(`.${className}`) || upTarget.closest(`.${className}`));
if (!isInside) {
callbackRef.current();
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [enabled, ignoreIfMoved, clickType, ...dependencies]);
}