Refactor:리액트 윈도우 추가 드래그 앤 드랍 추가

This commit is contained in:
정현조 2024-10-04 02:55:06 +09:00
parent 6b4946b273
commit dacc7e357e
2 changed files with 217 additions and 152 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { TreeNode } from 'react-treebeard'; import { TreeNode } from 'react-treebeard';
import useProjectStore from '@/stores/useProjectStore'; import useProjectStore from '@/stores/useProjectStore';
import useCanvasStore from '@/stores/useCanvasStore'; import useCanvasStore from '@/stores/useCanvasStore';
@ -9,7 +9,22 @@ import AutoLabelButton from './AutoLabelButton';
import useMoveImageQuery from '@/queries/images/useMoveImageQuery'; import useMoveImageQuery from '@/queries/images/useMoveImageQuery';
import { Folder, Image as ImageIcon, Minus, Loader, ArrowDownToLine, Send, CircleSlash, Check } from 'lucide-react'; import { Folder, Image as ImageIcon, Minus, Loader, ArrowDownToLine, Send, CircleSlash, Check } from 'lucide-react';
import { Spinner } from '../ui/spinner'; import { Spinner } from '../ui/spinner';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
interface FlatNode extends TreeNode {
depth: number;
isLeaf: boolean;
parent?: FlatNode;
index?: number;
}
const ItemTypes = {
NODE: 'node',
};
export default function ProjectStructure({ project }: { project: Project }) { export default function ProjectStructure({ project }: { project: Project }) {
const { setProject } = useProjectStore(); const { setProject } = useProjectStore();
@ -18,11 +33,20 @@ export default function ProjectStructure({ project }: { project: Project }) {
const [cursor, setCursor] = useState<TreeNode | null>(null); const [cursor, setCursor] = useState<TreeNode | null>(null);
const moveImageMutation = useMoveImageQuery(); const moveImageMutation = useMoveImageQuery();
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(400);
useEffect(() => { useEffect(() => {
setProject(project); setProject(project);
initializeTree(); initializeTree();
}, [project, setProject, initializeTree]); }, [project, setProject, initializeTree]);
useEffect(() => {
if (containerRef.current) {
setContainerHeight(containerRef.current.clientHeight);
}
}, [containerRef, treeData, isLoading]);
const onToggle = useCallback( const onToggle = useCallback(
async (node: TreeNode, toggled: boolean) => { async (node: TreeNode, toggled: boolean) => {
if (cursor) { if (cursor) {
@ -37,15 +61,27 @@ export default function ProjectStructure({ project }: { project: Project }) {
if (toggled && (!node.children || node.children.length === 0)) { if (toggled && (!node.children || node.children.length === 0)) {
await fetchNodeData(node); await fetchNodeData(node);
} }
node.toggled = toggled;
}
setTreeData((prevData) => ({ ...prevData! })); const updateNode = (currentNode: TreeNode): TreeNode => {
if (currentNode.id === node.id) {
return { ...currentNode, toggled };
}
if (currentNode.children) {
return {
...currentNode,
children: currentNode.children.map(updateNode),
};
}
return currentNode;
};
setTreeData((prevData) => prevData && updateNode(prevData));
}
}, },
[cursor, fetchNodeData, setImage, setTreeData] [cursor, fetchNodeData, setImage, setTreeData]
); );
const renderStatusIcon = (status: ImageStatus) => { const renderStatusIcon = useCallback((status: ImageStatus) => {
switch (status) { switch (status) {
case 'PENDING': case 'PENDING':
return ( return (
@ -91,124 +127,72 @@ export default function ProjectStructure({ project }: { project: Project }) {
/> />
); );
} }
}; }, []);
const renderTree = useCallback( const flattenTree = useCallback((nodes: TreeNode[], depth: number = 0, parent?: FlatNode): FlatNode[] => {
(nodes: TreeNode[], _parentId: string, level: number = 0) => { let flatList: FlatNode[] = [];
return nodes.map((node, index) => (
<Draggable
draggableId={node.id!}
index={index}
key={node.id}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
display: 'flex',
flexDirection: 'column',
}}
>
<div
{...provided.dragHandleProps}
style={{
display: 'flex',
alignItems: 'center',
paddingLeft: `${level * 20}px`,
cursor: 'pointer',
}}
onClick={() => onToggle(node, !node.toggled)}
>
<div style={{ marginRight: '5px' }}>
{!node.imageData ? (
<Folder
size={16}
className="stroke-gray-500"
/>
) : (
<ImageIcon
size={16}
className="stroke-gray-500"
/>
)}
</div>
<span style={{ color: '#4a4a4a', flexGrow: 1 }}>{node.name}</span>
{node.imageData && <div style={{ marginLeft: '10px' }}>{renderStatusIcon(node.imageData.status)}</div>}
</div>
{node.toggled && node.children && node.children.length > 0 && (
<Droppable
droppableId={node.id!}
type="TREE"
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
style={{ paddingLeft: `${level * 20}px` }}
>
{renderTree(node.children!, node.id!, level + 1)}
{provided.placeholder}
</div>
)}
</Droppable>
)}
</div>
)}
</Draggable>
));
},
[onToggle]
);
const onDragEnd = useCallback( nodes.forEach((node, index) => {
(result: DropResult) => { const flatNode: FlatNode = {
if (!result.destination || !treeData) { ...node,
return; depth,
} isLeaf: !node.children || node.children.length === 0,
parent,
const sourceDroppableId = result.source.droppableId; index,
const destinationDroppableId = result.destination.droppableId;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
const findNodeById = (nodes: TreeNode[], id: string): TreeNode | null => {
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = findNodeById(node.children, id);
if (found) return found;
}
}
return null;
}; };
flatList.push(flatNode);
const sourceParentNode = sourceDroppableId === 'root' ? treeData : findNodeById([treeData], sourceDroppableId); if (node.toggled && node.children) {
const destinationParentNode = flatList = flatList.concat(flattenTree(node.children, depth + 1, flatNode));
destinationDroppableId === 'root' ? treeData : findNodeById([treeData], destinationDroppableId);
if (!sourceParentNode || !destinationParentNode) {
return;
} }
});
const [movedItem] = sourceParentNode.children!.splice(sourceIndex, 1); return flatList;
}, []);
destinationParentNode.children!.splice(destinationIndex, 0, movedItem); const flatData = useMemo(() => {
if (!treeData) return [];
return flattenTree([treeData]);
}, [treeData, flattenTree]);
setTreeData({ ...treeData }); const getItemKey = (index: number, data: FlatNode[]) => data[index].id!;
if (movedItem && movedItem.imageData) { const moveNode = useCallback(
const moveFolderId = Number(destinationParentNode.id) || 0; (dragItem: FlatNode, hoverItem: FlatNode) => {
const folderId = Number(sourceParentNode.id) || 0; const updatedTreeData = (function moveNodeInTree(node: TreeNode): TreeNode {
if (node.id === dragItem.parent?.id) {
const newChildren = node.children?.filter((child) => child.id !== dragItem.id) || [];
return { ...node, children: newChildren };
}
if (node.id === hoverItem.parent?.id) {
const newChildren = [...(node.children || [])];
const hoverIndex = newChildren.findIndex((child) => child.id === hoverItem.id);
newChildren.splice(hoverIndex, 0, { ...dragItem, parent: hoverItem.parent } as FlatNode);
return { ...node, children: newChildren };
}
if (node.children) {
return {
...node,
children: node.children.map(moveNodeInTree),
};
}
return node;
})(treeData!);
setTreeData(updatedTreeData);
if (dragItem.imageData) {
const moveFolderId = Number(hoverItem.parent?.id) || 0;
const folderId = Number(dragItem.parent?.id) || 0;
const projectId = Number(project.id); const projectId = Number(project.id);
moveImageMutation.mutate({ moveImageMutation.mutate({
projectId, projectId,
folderId, folderId,
imageId: movedItem.imageData.id, imageId: dragItem.imageData.id,
moveRequest: { moveRequest: {
moveFolderId, moveFolderId,
}, },
@ -218,49 +202,112 @@ export default function ProjectStructure({ project }: { project: Project }) {
[treeData, setTreeData, moveImageMutation, project.id] [treeData, setTreeData, moveImageMutation, project.id]
); );
const Row = ({ index, style, data }: ListChildComponentProps<FlatNode[]>) => {
const node = data[index];
const ref = useRef<HTMLDivElement>(null);
const [, drop] = useDrop({
accept: ItemTypes.NODE,
drop(item: FlatNode) {
const dragItem = item;
const hoverItem = node;
if (dragItem.id === hoverItem.id) {
return;
}
// 드래그가 끝났을 때 노드 이동 처리
moveNode(dragItem, hoverItem);
},
});
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.NODE,
item: () => ({ ...node, index }),
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<div
ref={ref}
style={{
...style,
opacity: isDragging ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
paddingLeft: `${node.depth * 20}px`,
cursor: 'pointer',
}}
onClick={() => onToggle(node, !node.toggled)}
>
<div style={{ marginRight: '5px' }}>
{!node.imageData ? (
<Folder
size={16}
className="stroke-gray-500"
/>
) : (
<ImageIcon
size={16}
className="stroke-gray-500"
/>
)}
</div>
<span style={{ color: '#4a4a4a', flexGrow: 1 }}>{node.name}</span>
{node.imageData && <div style={{ marginLeft: '10px' }}>{renderStatusIcon(node.imageData.status)}</div>}
</div>
);
};
return ( return (
<div className="box-border flex h-full min-h-0 flex-col bg-gray-50 p-2"> <DndProvider backend={HTML5Backend}>
<div className="flex h-full flex-col gap-2 overflow-hidden px-1 pb-2"> <div
<header className="flex w-full items-center gap-2 rounded-md bg-white p-2 shadow-sm"> className="box-border flex h-full min-h-0 flex-col bg-gray-50 p-2"
<div className="flex w-full min-w-0 items-center gap-1 pr-1"> style={{ overflowX: 'hidden' }}
<h2 className="caption overflow-hidden text-ellipsis whitespace-nowrap text-gray-600">{project.type}</h2> ref={containerRef}
</div> >
<WorkspaceDropdownMenu <div className="flex h-full flex-col gap-2 overflow-hidden px-1 pb-2">
projectId={project.id} <header className="flex w-full items-center gap-2 rounded-md bg-white p-2 shadow-sm">
folderId={0} <div className="flex w-full min-w-0 items-center gap-1 pr-1">
onRefetch={() => {}} <h2 className="caption overflow-hidden text-ellipsis whitespace-nowrap text-gray-600">{project.type}</h2>
/> </div>
</header> <WorkspaceDropdownMenu
{isLoading ? ( projectId={project.id}
<div className="flex h-full items-center justify-center"> folderId={0}
<Spinner /> onRefetch={() => {}}
</div> />
) : !treeData ? ( </header>
<div className="body-small flex h-full select-none items-center justify-center text-gray-400">Loading...</div> {isLoading ? (
) : ( <div className="flex h-full items-center justify-center">
<DragDropContext onDragEnd={onDragEnd}> <Spinner />
<Droppable </div>
droppableId="root" ) : !treeData ? (
type="TREE" <div className="body-small flex h-full select-none items-center justify-center text-gray-400">
Loading...
</div>
) : (
<List
height={Math.min(flatData.length * 40, containerHeight)}
itemCount={flatData.length}
itemSize={40}
width={'100%'}
itemData={flatData}
itemKey={getItemKey}
className="flex-1 overflow-auto"
style={{ overflowX: 'hidden' }}
> >
{(provided) => ( {Row}
<div </List>
ref={provided.innerRef} )}
{...provided.droppableProps} </div>
className="flex-1 overflow-auto" <div className="flex">
style={{ overflowX: 'hidden' }} <AutoLabelButton projectId={project.id} />
> </div>
{renderTree(treeData.children!, 'root')}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div> </div>
<div className="flex"> </DndProvider>
<AutoLabelButton projectId={project.id} />
</div>
</div>
); );
} }

View File

@ -31,7 +31,25 @@ export default function useTreeData(projectId: string, initialFolderId: number)
node.children = [...childFolders, ...images]; node.children = [...childFolders, ...images];
node.loading = false; node.loading = false;
node.toggled = true; node.toggled = true;
setTreeData((prevData) => ({ ...prevData! }));
setTreeData((prevData) => {
if (!prevData) return null;
const updateNode = (currentNode: TreeNode): TreeNode => {
if (currentNode.id === node.id) {
return { ...node };
}
if (currentNode.children) {
return {
...currentNode,
children: currentNode.children.map(updateNode),
};
}
return currentNode;
};
return updateNode(prevData);
});
} catch (error) { } catch (error) {
node.loading = false; node.loading = false;
console.error(`Error fetching data for node ${node.id}:`, error); console.error(`Error fetching data for node ${node.id}:`, error);