Skip to content

Commit

Permalink
chore(web): Add list style common DnD component (#585)
Browse files Browse the repository at this point in the history
  • Loading branch information
isoppp committed Jul 26, 2023
1 parent 517b1b4 commit ad4ae6d
Show file tree
Hide file tree
Showing 5 changed files with 399 additions and 41 deletions.
109 changes: 109 additions & 0 deletions 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<Props> = ({
itemGroupKey,
id,
children,
index,
onItemMove,
onItemDropOnItem,
onItemDropOutside,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [{ handlerId }, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>({
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 (
<SItem ref={ref} data-handler-id={handlerId} isDragging={isDragging}>
{children}
</SItem>
);
};

export default memo(Item);

const SItem = styled.div<{ isDragging: boolean }>`
${({ isDragging }) => `opacity: ${isDragging ? 0 : 1};`}
cursor: move;
`;
70 changes: 70 additions & 0 deletions 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<typeof DragAndDropList<DummyItem>> = {
component: DragAndDropList<DummyItem>,
};

export default meta;

type Story = StoryObj<typeof DragAndDropList<DummyItem>>;

const dummyItems: DummyItem[] = [...Array(10)].map((_, i) => {
const str = `${i} Sample ID / Text`;
return { id: str, text: str };
});

const DummyComponent: typeof DragAndDropList<DummyItem> = args => {
const [items, setItems] = useState<DummyItem[]>(dummyItems);
return (
<DragAndDropList<DummyItem>
{...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 (
<div style={{ maxHeight: "240px", overflowY: "auto", background: "gray", padding: "24px" }}>
<DummyComponent {...args} />
</div>
);
},
args: {
uniqueKey: "uniqueKey",
renderItem: item => (
<div
style={{
border: "1px solid blue",
padding: "0.5rem 1rem",
backgroundColor: "lightgray",
}}>
{item.text}
</div>
),
getId: item => item.id.toString(),
items: dummyItems,
gap: 16,
},
};
79 changes: 79 additions & 0 deletions 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<Item extends { id: string } = { id: string }> = {
uniqueKey: string;
items: Item[];
getId: (item: Item) => string;
onItemDrop(item: Item, targetIndex: number): void;
renderItem: (item: Item) => ReactNode;
gap: number;
};

function DragAndDropList<Item extends { id: string } = { id: string }>({
uniqueKey,
items,
onItemDrop,
getId,
renderItem,
gap,
}: Props<Item>) {
const [movingItems, setMovingItems] = useState<Item[]>(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 (
<SWrapper gap={gap}>
{movingItems.map((item, i) => {
const id = getId(item);
return (
<Item
itemGroupKey={uniqueKey}
key={id}
id={item.id}
index={i}
onItemMove={onItemMove}
onItemDropOnItem={onItemDropOnItem}
onItemDropOutside={onItemDropOutside}>
{renderItem(item)}
</Item>
);
})}
</SWrapper>
);
}

export default DragAndDropList;

const SWrapper = styled.div<Pick<Props, "gap">>`
display: flex;
flex-direction: column;
${({ gap }) => `gap: ${gap}px`}
`;
@@ -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";
Expand All @@ -22,49 +23,74 @@ const ContentPage: React.FC<Props> = ({
const t = useT();
const [openedPageId, setOpenedPageId] = useState<string | undefined>(undefined);

const [items, setItems] = useState(
[...Array(100)].map((_, i) => ({
id: i.toString(),
index: i,
text: "page" + i,
})),
);
return (
<SContent>
<SContentUp onScroll={openedPageId ? () => setOpenedPageId(undefined) : undefined}>
{[...Array(100)].map((_, i) => (
<PageItemWrapper key={i} pageCount={i + 1} isSwipable={i % 2 === 0}>
<ListItem
key={i}
border
onItemClick={() => 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={
<PopoverMenuContent
width="120px"
size="md"
items={[
{
icon: "copy",
name: "Duplicate",
onClick: () => {
setOpenedPageId(undefined);
onPageDuplicate(i.toString());
},
},
{
icon: "trash",
name: "Delete",
onClick: () => {
setOpenedPageId(undefined);
onPageDelete(i.toString());
},
},
]}
/>
}>
Page
</ListItem>
</PageItemWrapper>
))}
<DragAndDropList
uniqueKey="LeftPanelPages"
gap={8}
items={items}
getId={item => 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 (
<PageItemWrapper pageCount={item.index + 1} isSwipable={item.index % 2 === 0}>
<ListItem
border
onItemClick={() => 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={
<PopoverMenuContent
width="120px"
size="md"
items={[
{
icon: "copy",
name: "Duplicate",
onClick: () => {
setOpenedPageId(undefined);
onPageDuplicate(item.id);
},
},
{
icon: "trash",
name: "Delete",
onClick: () => {
setOpenedPageId(undefined);
onPageDelete(item.id);
},
},
]}
/>
}>
Page
</ListItem>
</PageItemWrapper>
);
}}
/>
</SContentUp>
<SContentBottom>
<Action icon="square" title={`+ ${t("New Page")}`} onClick={onPageAdd} />
Expand Down

0 comments on commit ad4ae6d

Please sign in to comment.