import type { Meta, StoryFn } from '@storybook/react';
import { cssVar } from '@toeverything/theme';
import { cloneDeep } from 'lodash-es';
import { useCallback, useState } from 'react';
import {
type DNDData,
DropIndicator,
type DropTargetDropEvent,
type DropTargetOptions,
useDraggable,
useDropTarget,
} from './index';
export default {
title: 'UI/Dnd',
} satisfies Meta;
export const Draggable: StoryFn<{
canDrag: boolean;
disableDragPreview: boolean;
}> = ({ canDrag, disableDragPreview }) => {
const { dragRef } = useDraggable(
() => ({
canDrag,
disableDragPreview,
}),
[canDrag, disableDragPreview]
);
return (
);
};
Draggable.args = {
canDrag: true,
disableDragPreview: false,
};
export const DraggableCustomPreview: StoryFn = () => {
const { dragRef, CustomDragPreview } = useDraggable(() => ({}), []);
return (
);
};
export const DraggableControlledPreview: StoryFn = () => {
const { dragRef, draggingPosition } = useDraggable(
() => ({
disableDragPreview: true,
}),
[]
);
return (
);
};
export const DropTarget: StoryFn<{ canDrop: boolean }> = ({ canDrop }) => {
const [dropData, setDropData] = useState('');
const { dragRef } = useDraggable(
() => ({
data: { text: 'hello' },
}),
[]
);
const { dropTargetRef } = useDropTarget(
() => ({
canDrop,
onDrop(data) {
setDropData(prev => prev + data.source.data.text);
},
}),
[canDrop]
);
return (
π hello
{dropData || 'Drop here'}
);
};
DropTarget.args = {
canDrop: true,
};
const DropList = ({ children }: { children?: React.ReactNode }) => {
const [dropData, setDropData] = useState([]);
const { dropTargetRef, draggedOver } = useDropTarget<
DNDData<{ text: string }>
>(
() => ({
onDrop(data) {
setDropData(prev => [...prev, data.source.data.text]);
},
}),
[]
);
return (
- Append here{draggedOver && ' [dragged-over]'}
{dropData.map((text, i) => (
- {text}
))}
{children}
);
};
export const NestedDropTarget: StoryFn<{ canDrop: boolean }> = () => {
const { dragRef } = useDraggable(
() => ({
data: { text: 'hello' },
}),
[]
);
return (
);
};
NestedDropTarget.args = {
canDrop: true,
};
export const DynamicDragPreview = () => {
type DataType = DNDData<
Record,
{ type: 'big' | 'small' | 'tips' }
>;
const { dragRef, dragging, draggingPosition, dropTarget, CustomDragPreview } =
useDraggable(() => ({}), []);
const { dropTargetRef: bigDropTargetRef } = useDropTarget(
() => ({
data: { type: 'big' },
}),
[]
);
const { dropTargetRef: smallDropTargetRef } = useDropTarget(
() => ({
data: { type: 'small' },
}),
[]
);
const {
dropTargetRef: tipsDropTargetRef,
draggedOver: tipsDraggedOver,
draggedOverPosition: tipsDraggedOverPosition,
} = useDropTarget(
() => ({
data: { type: 'tips' },
}),
[]
);
return (
0 ? `translate(${draggingPosition.offsetX}px, ${draggingPosition.offsetY}px)` : `translate(${draggingPosition.offsetX}px, 0px)`}
${dropTarget.some(t => t.data.type === 'big') ? 'scale(1.5)' : dropTarget.some(t => t.data.type === 'small') ? 'scale(0.5)' : ''}
${draggingPosition.outWindow ? 'scale(0.0)' : ''}`,
opacity: draggingPosition.outWindow ? 0.2 : 1,
pointerEvents: dragging ? 'none' : 'auto',
transition: 'transform 50ms, opacity 200ms',
marginBottom: '100px',
willChange: 'transform',
background: cssVar('--affine-background-primary-color'),
}}
>
π drag here
Big
Small
Tips
{tipsDraggedOver && (
tips
)}
π this is a record
);
};
const ReorderableListItem = ({
id,
onDrop,
orientation,
}: {
id: string;
onDrop: DropTargetOptions['onDrop'];
orientation: 'horizontal' | 'vertical';
}) => {
const { dropTargetRef, closestEdge } = useDropTarget(
() => ({
isSticky: true,
closestEdge: {
allowedEdges:
orientation === 'vertical' ? ['top', 'bottom'] : ['left', 'right'],
},
onDrop,
}),
[onDrop, orientation]
);
const { dragRef } = useDraggable(
() => ({
data: { id },
}),
[id]
);
return (
{
dropTargetRef.current = node;
dragRef.current = node;
}}
style={{
position: 'relative',
padding: '10px',
border: '1px solid black',
}}
>
Item {id}
);
};
export const ReorderableList: StoryFn<{
orientation: 'horizontal' | 'vertical';
}> = ({ orientation }) => {
const [items, setItems] = useState(['A', 'B', 'C']);
return (
{items.map((item, i) => (
{
const dropId = data.source.data.id as string;
if (dropId === item) {
return;
}
const closestEdge = data.closestEdge;
if (!closestEdge) {
return;
}
const newItems = items.filter(i => i !== dropId);
const newPosition = newItems.findIndex(i => i === item);
newItems.splice(
closestEdge === 'bottom' || closestEdge === 'right'
? newPosition + 1
: newPosition,
0,
dropId
);
setItems(newItems);
}}
/>
))}
);
};
ReorderableList.argTypes = {
orientation: {
type: {
name: 'enum',
value: ['horizontal', 'vertical'],
required: true,
},
},
};
ReorderableList.args = {
orientation: 'vertical',
};
interface Node {
id: string;
children: Node[];
leaf?: boolean;
}
const ReorderableTreeNode = ({
level,
node,
onDrop,
isLastInGroup,
}: {
level: number;
node: Node;
onDrop: (
data: DropTargetDropEvent> & {
dropAt: Node;
}
) => void;
isLastInGroup: boolean;
}) => {
const [expanded, setExpanded] = useState(true);
const { dragRef, dragging } = useDraggable(
() => ({
data: { node },
}),
[node]
);
const { dropTargetRef, treeInstruction } = useDropTarget<
DNDData<{
node: Node;
}>
>(
() => ({
isSticky: true,
treeInstruction: {
mode:
expanded && !node.leaf
? 'expanded'
: isLastInGroup
? 'last-in-group'
: 'standard',
block: node.leaf ? ['make-child'] : [],
currentLevel: level,
indentPerLevel: 20,
},
onDrop: data => {
onDrop({ ...data, dropAt: node });
},
}),
[onDrop, expanded, isLastInGroup, level, node]
);
return (
<>
{
dropTargetRef.current = node;
dragRef.current = node;
}}
style={{
paddingLeft: level * 20,
position: 'relative',
}}
>
setExpanded(prev => !prev)}>
{node.leaf ? 'π ' : expanded ? 'π ' : 'π '}
{node.id}
{expanded &&
!dragging &&
node.children.map((child, i) => (
))}
>
);
};
export const ReorderableTree: StoryFn = () => {
const [tree, setTree] = useState({
id: 'root',
children: [
{
id: 'a',
children: [],
},
{
id: 'b',
children: [
{
id: 'c',
children: [],
leaf: true,
},
{
id: 'd',
children: [],
leaf: true,
},
{
id: 'e',
children: [
{
id: 'f',
children: [],
leaf: true,
},
],
},
],
},
],
});
const handleDrop = useCallback(
(
data: DropTargetDropEvent> & {
dropAt: Node;
}
) => {
const clonedTree = cloneDeep(tree);
const findNode = (
node: Node,
id: string
): { parent: Node; index: number; node: Node } | null => {
if (node.id === id) {
return { parent: node, index: -1, node };
}
for (let i = 0; i < node.children.length; i++) {
if (node.children[i].id === id) {
return { parent: node, index: i, node: node.children[i] };
}
const result = findNode(node.children[i], id);
if (result) {
return result;
}
}
return null;
};
const nodePosition = findNode(clonedTree, data.source.data.node.id)!;
const dropAtPosition = findNode(clonedTree, data.dropAt.id)!;
// delete the node from the tree
nodePosition.parent.children.splice(nodePosition.index, 1);
if (data.treeInstruction) {
if (data.treeInstruction.type === 'make-child') {
if (dropAtPosition.node.leaf) {
return;
}
if (nodePosition.node.id === dropAtPosition.node.id) {
return;
}
dropAtPosition.node.children.splice(0, 0, nodePosition.node);
} else if (data.treeInstruction.type === 'reparent') {
const up =
data.treeInstruction.currentLevel -
data.treeInstruction.desiredLevel -
1;
let parentPosition = findNode(clonedTree, dropAtPosition.parent.id)!;
for (let i = 0; i < up; i++) {
parentPosition = findNode(clonedTree, parentPosition.parent.id)!;
}
parentPosition.parent.children.splice(
parentPosition.index + 1,
0,
nodePosition.node
);
} else if (data.treeInstruction.type === 'reorder-above') {
if (dropAtPosition.node.id === 'root') {
return;
}
dropAtPosition.parent.children.splice(
dropAtPosition.index,
0,
nodePosition.node
);
} else if (data.treeInstruction.type === 'reorder-below') {
if (dropAtPosition.node.id === 'root') {
return;
}
dropAtPosition.parent.children.splice(
dropAtPosition.index + 1,
0,
nodePosition.node
);
} else if (data.treeInstruction.type === 'instruction-blocked') {
return;
}
setTree(clonedTree);
}
},
[tree]
);
return (
);
};
ReorderableList.argTypes = {
orientation: {
type: {
name: 'enum',
value: ['horizontal', 'vertical'],
required: true,
},
},
};
ReorderableList.args = {
orientation: 'vertical',
};