Refactor:리액트 윈도우 추가 드래그 앤 드랍 추가
This commit is contained in:
parent
6b4946b273
commit
dacc7e357e
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
Loading…
Reference in New Issue
Block a user