diff --git a/web/src/beta/components/DragAndDropList/Item.tsx b/web/src/beta/components/DragAndDropList/Item.tsx new file mode 100644 index 0000000000..9626e546d9 --- /dev/null +++ b/web/src/beta/components/DragAndDropList/Item.tsx @@ -0,0 +1,109 @@ +import type { Identifier } from "dnd-core"; +import type { FC, ReactNode } from "react"; +import { memo, useRef } from "react"; +import { useDrag, useDrop } from "react-dnd"; + +import { styled } from "@reearth/services/theme"; + +type DragItem = { + index: number; + id: string; + type: string; +}; + +type Props = { + itemGroupKey: string; + id: string; + index: number; + onItemMove: (dragIndex: number, hoverIndex: number) => void; + onItemDropOnItem: (dropIndex: number) => void; + onItemDropOutside: () => void; + children: ReactNode; +}; + +const Item: FC = ({ + itemGroupKey, + id, + children, + index, + onItemMove, + onItemDropOnItem, + onItemDropOutside, +}) => { + const ref = useRef(null); + const [{ handlerId }, drop] = useDrop({ + accept: itemGroupKey, + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DragItem, monitor) { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) return; + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + + // Get pixels to the top + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // Get vertical middle Y + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + onItemMove(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + drop(item) { + onItemDropOnItem(item.index); + }, + }); + + const [{ isDragging }, drag] = useDrag({ + type: itemGroupKey, + item: () => { + return { id, index }; + }, + collect: monitor => ({ + isDragging: monitor.isDragging(), + }), + end: (item, monitor) => { + const didDrop = monitor.didDrop(); + if (didDrop) { + onItemDropOnItem(item.index); + } else { + onItemDropOutside(); + } + }, + }); + + drag(drop(ref)); + return ( + + {children} + + ); +}; + +export default memo(Item); + +const SItem = styled.div<{ isDragging: boolean }>` + ${({ isDragging }) => `opacity: ${isDragging ? 0 : 1};`} + cursor: move; +`; diff --git a/web/src/beta/components/DragAndDropList/index.stories.tsx b/web/src/beta/components/DragAndDropList/index.stories.tsx new file mode 100644 index 0000000000..971bb47f56 --- /dev/null +++ b/web/src/beta/components/DragAndDropList/index.stories.tsx @@ -0,0 +1,70 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; + +import DragAndDropList from "."; + +type DummyItem = { + id: string; + text: string; +}; + +const meta: Meta> = { + component: DragAndDropList, +}; + +export default meta; + +type Story = StoryObj>; + +const dummyItems: DummyItem[] = [...Array(10)].map((_, i) => { + const str = `${i} Sample ID / Text`; + return { id: str, text: str }; +}); + +const DummyComponent: typeof DragAndDropList = args => { + const [items, setItems] = useState(dummyItems); + return ( + + {...args} + items={items} + onItemDrop={(item, index) => { + // actual use case would be api call or optimistic update + setItems(old => { + const items = [...old]; + items.splice( + old.findIndex(o => o.id === item.id), + 1, + ); + items.splice(index, 0, item); + return items; + }); + }} + /> + ); +}; + +export const Default: Story = { + render: args => { + return ( +
+ +
+ ); + }, + args: { + uniqueKey: "uniqueKey", + renderItem: item => ( +
+ {item.text} +
+ ), + getId: item => item.id.toString(), + items: dummyItems, + gap: 16, + }, +}; diff --git a/web/src/beta/components/DragAndDropList/index.tsx b/web/src/beta/components/DragAndDropList/index.tsx new file mode 100644 index 0000000000..b9405e8546 --- /dev/null +++ b/web/src/beta/components/DragAndDropList/index.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; + +import { styled } from "@reearth/services/theme"; + +import Item from "./Item"; + +type Props = { + uniqueKey: string; + items: Item[]; + getId: (item: Item) => string; + onItemDrop(item: Item, targetIndex: number): void; + renderItem: (item: Item) => ReactNode; + gap: number; +}; + +function DragAndDropList({ + uniqueKey, + items, + onItemDrop, + getId, + renderItem, + gap, +}: Props) { + const [movingItems, setMovingItems] = useState(items); + + useEffect(() => { + setMovingItems(items); + }, [items]); + + const onItemMove = useCallback((dragIndex: number, hoverIndex: number) => { + setMovingItems(old => { + const items = [...old]; + items.splice(dragIndex, 1); + items.splice(hoverIndex, 0, old[dragIndex]); + return items; + }); + }, []); + + const onItemDropOnItem = useCallback( + (index: number) => { + const item = movingItems[index]; + item && onItemDrop(movingItems[index], index); + }, + [movingItems, onItemDrop], + ); + + const onItemDropOutside = useCallback(() => { + setMovingItems(items); + }, [items]); + + return ( + + {movingItems.map((item, i) => { + const id = getId(item); + return ( + + {renderItem(item)} + + ); + })} + + ); +} + +export default DragAndDropList; + +const SWrapper = styled.div>` + display: flex; + flex-direction: column; + ${({ gap }) => `gap: ${gap}px`} +`; diff --git a/web/src/beta/features/Editor/tabs/story/SidePanel/ContentPage/index.tsx b/web/src/beta/features/Editor/tabs/story/SidePanel/ContentPage/index.tsx index 830872d0d2..a2eef19241 100644 --- a/web/src/beta/features/Editor/tabs/story/SidePanel/ContentPage/index.tsx +++ b/web/src/beta/features/Editor/tabs/story/SidePanel/ContentPage/index.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; +import DragAndDropList from "@reearth/beta/components/DragAndDropList"; import ListItem from "@reearth/beta/components/ListItem"; import PopoverMenuContent from "@reearth/beta/components/PopoverMenuContent"; import Action from "@reearth/beta/features/Editor/tabs/story/SidePanel/Action"; @@ -22,49 +23,74 @@ const ContentPage: React.FC = ({ const t = useT(); const [openedPageId, setOpenedPageId] = useState(undefined); + const [items, setItems] = useState( + [...Array(100)].map((_, i) => ({ + id: i.toString(), + index: i, + text: "page" + i, + })), + ); return ( setOpenedPageId(undefined) : undefined}> - {[...Array(100)].map((_, i) => ( - - onPageSelect(i.toString())} - onActionClick={() => setOpenedPageId(old => (old ? undefined : i.toString()))} - onOpenChange={isOpen => { - setOpenedPageId(isOpen ? i.toString() : undefined); - }} - isSelected={i === 0} - isOpenAction={openedPageId === i.toString()} - actionContent={ - { - setOpenedPageId(undefined); - onPageDuplicate(i.toString()); - }, - }, - { - icon: "trash", - name: "Delete", - onClick: () => { - setOpenedPageId(undefined); - onPageDelete(i.toString()); - }, - }, - ]} - /> - }> - Page - - - ))} + item.id} + onItemDrop={(item, index) => { + setItems(old => { + const items = [...old]; + items.splice( + old.findIndex(o => o.id === item.id), + 1, + ); + items.splice(index, 0, item); + return items; + }); + }} + renderItem={item => { + return ( + + onPageSelect(item.id)} + onActionClick={() => setOpenedPageId(old => (old ? undefined : item.id))} + onOpenChange={isOpen => { + setOpenedPageId(isOpen ? item.id : undefined); + }} + isSelected={item.index === 0} + isOpenAction={openedPageId === item.id} + actionContent={ + { + setOpenedPageId(undefined); + onPageDuplicate(item.id); + }, + }, + { + icon: "trash", + name: "Delete", + onClick: () => { + setOpenedPageId(undefined); + onPageDelete(item.id); + }, + }, + ]} + /> + }> + Page + + + ); + }} + /> diff --git a/web/src/beta/features/Editor/tabs/story/SidePanel/ContentStory/index.tsx b/web/src/beta/features/Editor/tabs/story/SidePanel/ContentStory/index.tsx index fdee18dc63..3908c6f396 100644 --- a/web/src/beta/features/Editor/tabs/story/SidePanel/ContentStory/index.tsx +++ b/web/src/beta/features/Editor/tabs/story/SidePanel/ContentStory/index.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; +import DragAndDropList from "@reearth/beta/components/DragAndDropList"; import ListItem from "@reearth/beta/components/ListItem"; import PopoverMenuContent from "@reearth/beta/components/PopoverMenuContent"; import Action from "@reearth/beta/features/Editor/tabs/story/SidePanel/Action"; +import PageItemWrapper from "@reearth/beta/features/Editor/tabs/story/SidePanel/PageItemWrapper"; import { styled } from "@reearth/services/theme"; type Props = { @@ -23,10 +25,82 @@ const ContentStory: React.FC = ({ onStoryRename, }) => { const [openedPageId, setOpenedPageId] = useState(undefined); - + const [items, setItems] = useState( + [...Array(100)].map((_, i) => ({ + id: i.toString(), + index: i, + text: "page" + i, + })), + ); return ( setOpenedPageId(undefined) : undefined}> + item.id} + onItemDrop={(item, index) => { + setItems(old => { + const items = [...old]; + items.splice( + old.findIndex(o => o.id === item.id), + 1, + ); + items.splice(index, 0, item); + return items; + }); + }} + renderItem={item => { + return ( + + onStorySelect(item.id)} + onActionClick={() => setOpenedPageId(old => (old ? undefined : item.id))} + onOpenChange={isOpen => { + setOpenedPageId(isOpen ? item.id : undefined); + }} + isSelected={item.index === 0} + isOpenAction={openedPageId === item.id} + actionContent={ + { + setOpenedPageId(undefined); + onStoryRename(item.id); + }, + }, + { + icon: "gearSix", + name: "Settings", + onClick: () => { + setOpenedPageId(undefined); + onStoryClickSettings(item.id); + }, + }, + { + icon: "trash", + name: "Delete Story", + onClick: () => { + setOpenedPageId(undefined); + onStoryDelete(item.id); + }, + }, + ]} + /> + }> + Page + + + ); + }} + /> {[...Array(100)].map((_, i) => (