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 (
Drag here
); }; Draggable.args = { canDrag: true, disableDragPreview: false, }; export const DraggableCustomPreview: StoryFn = () => { const { dragRef, CustomDragPreview } = useDraggable(() => ({}), []); return (
Drag here
Dragging🀌
); }; export const DraggableControlledPreview: StoryFn = () => { const { dragRef, draggingPosition } = useDraggable( () => ({ disableDragPreview: true, }), [] ); return (
Drag here
); }; 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 ( ); }; export const NestedDropTarget: StoryFn<{ canDrop: boolean }> = () => { const { dragRef } = useDraggable( () => ({ data: { text: 'hello' }, }), [] ); return (
πŸ‘‰ hello

); }; 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', };