From 7103b2e59497bb8b02fa700cd42bec3b874d2416 Mon Sep 17 00:00:00 2001 From: EYHN Date: Mon, 15 Jul 2024 04:00:41 +0000 Subject: [PATCH] feat(component): new dnd api (#7467) --- .eslintrc.js | 2 +- packages/frontend/component/package.json | 4 +- .../component/src/ui/dnd/dnd.stories.tsx | 587 ++++++++++++++++++ .../component/src/ui/dnd/draggable.ts | 242 ++++++++ .../src/ui/dnd/drop-indicator.css.ts | 166 +++++ .../component/src/ui/dnd/drop-indicator.tsx | 124 ++++ .../component/src/ui/dnd/drop-target.ts | 195 ++++++ .../frontend/component/src/ui/dnd/index.ts | 4 + .../frontend/component/src/ui/dnd/types.ts | 7 + yarn.lock | 47 +- 10 files changed, 1371 insertions(+), 7 deletions(-) create mode 100644 packages/frontend/component/src/ui/dnd/dnd.stories.tsx create mode 100644 packages/frontend/component/src/ui/dnd/draggable.ts create mode 100644 packages/frontend/component/src/ui/dnd/drop-indicator.css.ts create mode 100644 packages/frontend/component/src/ui/dnd/drop-indicator.tsx create mode 100644 packages/frontend/component/src/ui/dnd/drop-target.ts create mode 100644 packages/frontend/component/src/ui/dnd/index.ts create mode 100644 packages/frontend/component/src/ui/dnd/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index e1eb1430d..227bcac1d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -247,7 +247,7 @@ const config = { 'react-hooks/exhaustive-deps': [ 'warn', { - additionalHooks: 'useAsyncCallback', + additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)', }, ], }, diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index ca463f24a..3fe42b7b2 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -25,6 +25,8 @@ "@affine/electron-api": "workspace:*", "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", + "@atlaskit/pragmatic-drag-and-drop": "^1.2.1", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -103,7 +105,7 @@ "@vanilla-extract/css": "^1.14.2", "fake-indexeddb": "^6.0.0", "storybook": "^7.6.17", - "storybook-dark-mode": "4.0.2", + "storybook-dark-mode": "4.0.1", "typescript": "^5.4.5", "vite": "^5.2.8", "vitest": "1.6.0" diff --git a/packages/frontend/component/src/ui/dnd/dnd.stories.tsx b/packages/frontend/component/src/ui/dnd/dnd.stories.tsx new file mode 100644 index 000000000..1b6cf0653 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/dnd.stories.tsx @@ -0,0 +1,587 @@ +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 ( +
    +
  • Append here{draggedOver && ' [dragged-over]'}
  • + {dropData.map((text, i) => ( +
  • {text}
  • + ))} + {children} +
+ ); +}; + +export const NestedDropTarget: StoryFn<{ canDrop: boolean }> = () => { + const { dragRef } = useDraggable( + () => ({ + data: { text: 'hello' }, + }), + [] + ); + return ( +
+
πŸ‘‰ hello
+
+
    + + + + + +
+
+ ); +}; +NestedDropTarget.args = { + canDrop: true, +}; + +export const DynamicDragPreview = () => { + type DataType = DNDData, { type: 'big' | 'small' }>; + const { dragRef, dragging, draggingPosition, dropTarget, CustomDragPreview } = + useDraggable(() => ({}), []); + const { dropTargetRef: bigDropTargetRef } = useDropTarget( + () => ({ + data: { type: 'big' }, + }), + [] + ); + const { dropTargetRef: smallDropTargetRef } = useDropTarget( + () => ({ + data: { type: 'small' }, + }), + [] + ); + 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 +
+ +
+ πŸ‘‹ 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', +}; diff --git a/packages/frontend/component/src/ui/dnd/draggable.ts b/packages/frontend/component/src/ui/dnd/draggable.ts new file mode 100644 index 000000000..da60cb0fe --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/draggable.ts @@ -0,0 +1,242 @@ +import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer'; +import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; +import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; +import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/types'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import ReactDOM, { flushSync } from 'react-dom'; + +import type { DNDData } from './types'; + +type DraggableGetFeedback = Parameters< + NonNullable[0]['getInitialData']> +>[0]; + +type DraggableGet = T | ((data: DraggableGetFeedback) => T); + +function draggableGet( + get: T +): T extends undefined + ? undefined + : T extends DraggableGet + ? (args: DraggableGetFeedback) => I + : never { + if (get === undefined) { + return undefined as any; + } + return ((args: DraggableGetFeedback) => + typeof get === 'function' ? (get as any)(args) : get) as any; +} + +export interface DraggableOptions { + data?: DraggableGet; + dataForExternal?: DraggableGet<{ + [Key in + | 'text/uri-list' + | 'text/plain' + | 'text/html' + | 'Files' + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {})]?: string; + }>; + canDrag?: DraggableGet; + disableDragPreview?: boolean; +} + +export type DraggableCustomDragPreviewProps = React.PropsWithChildren<{ + position?: 'pointer-outside' | 'pointer-center' | 'native'; +}>; + +export const useDraggable = ( + getOptions: () => DraggableOptions = () => ({}), + deps: any[] = [] +) => { + const [dragging, setDragging] = useState(false); + const [draggingPosition, setDraggingPosition] = useState<{ + offsetX: number; + offsetY: number; + clientX: number; + clientY: number; + outWindow: boolean; + }>({ offsetX: 0, offsetY: 0, clientX: 0, clientY: 0, outWindow: false }); + const [dropTarget, setDropTarget] = useState< + (DropTargetRecord & { data: D['dropTarget'] })[] + >([]); + const [customDragPreviewPortal, setCustomDragPreviewPortal] = useState< + React.FC + >(() => () => null); + + const dragRef = useRef(null); + const dragHandleRef = useRef(null); + + const enableCustomDragPreview = useRef(false); + const enableDraggingPosition = useRef(false); + const enableDropTarget = useRef(false); + const enableDragging = useRef(false); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const options = useMemo(getOptions, deps); + + useEffect(() => { + if (!dragRef.current) { + return; + } + + const windowEvent = { + dragleave: () => { + setDraggingPosition(state => + state.outWindow === true ? state : { ...state, outWindow: true } + ); + }, + dragover: () => { + setDraggingPosition(state => + state.outWindow === true ? { ...state, outWindow: false } : state + ); + }, + }; + + const cleanupDraggable = draggable({ + element: dragRef.current, + dragHandle: dragHandleRef.current ?? undefined, + canDrag: draggableGet(options.canDrag), + getInitialData: draggableGet(options.data), + getInitialDataForExternal: draggableGet(options.dataForExternal), + onDragStart: args => { + if (enableDragging.current) { + setDragging(true); + } + if (enableDraggingPosition.current) { + document.body.addEventListener('dragleave', windowEvent.dragleave); + document.body.addEventListener('dragover', windowEvent.dragover); + setDraggingPosition({ + offsetX: 0, + offsetY: 0, + clientX: args.location.initial.input.clientX, + clientY: args.location.initial.input.clientY, + outWindow: false, + }); + } + if (enableDropTarget.current) { + setDropTarget([]); + } + if (dragRef.current) { + dragRef.current.dataset['dragging'] = 'true'; + } + }, + onDrop: () => { + if (enableDragging.current) { + setDragging(false); + } + if (enableDraggingPosition.current) { + document.body.removeEventListener('dragleave', windowEvent.dragleave); + document.body.removeEventListener('dragover', windowEvent.dragover); + setDraggingPosition({ + offsetX: 0, + offsetY: 0, + clientX: 0, + clientY: 0, + outWindow: false, + }); + } + if (enableDropTarget.current) { + setDropTarget([]); + } + if (dragRef.current) { + delete dragRef.current.dataset['dragging']; + } + }, + onDrag: args => { + if (enableDraggingPosition.current) { + setDraggingPosition(prev => ({ + offsetX: + args.location.current.input.clientX - + args.location.initial.input.clientX, + offsetY: + args.location.current.input.clientY - + args.location.initial.input.clientY, + clientX: args.location.current.input.clientX, + clientY: args.location.current.input.clientY, + outWindow: prev.outWindow, + })); + } + }, + onDropTargetChange(args) { + if (enableDropTarget.current) { + setDropTarget(args.location.current.dropTargets); + } + }, + onGenerateDragPreview({ nativeSetDragImage, source, location }) { + if (options.disableDragPreview) { + disableNativeDragPreview({ nativeSetDragImage }); + return; + } + if (enableCustomDragPreview.current) { + let previewPosition: DraggableCustomDragPreviewProps['position'] = + 'native'; + setCustomNativeDragPreview({ + getOffset: (...args) => { + if (previewPosition === 'pointer-center') { + return centerUnderPointer(...args); + } else if (previewPosition === 'pointer-outside') { + return pointerOutsideOfPreview({ + x: '8px', + y: '4px', + })(...args); + } else { + return preserveOffsetOnSource({ + element: source.element, + input: location.current.input, + })(...args); + } + }, + render({ container }) { + flushSync(() => { + setCustomDragPreviewPortal( + () => + ({ + children, + position, + }: DraggableCustomDragPreviewProps) => { + previewPosition = position; + return ReactDOM.createPortal(children, container); + } + ); + }); + return () => setCustomDragPreviewPortal(() => () => null); + }, + nativeSetDragImage, + }); + } + }, + }); + + return () => { + window.removeEventListener('dragleave', windowEvent.dragleave); + window.removeEventListener('dragover', windowEvent.dragover); + cleanupDraggable(); + }; + }, [options]); + + return { + get dragging() { + enableDragging.current = true; + return dragging; + }, + get draggingPosition() { + enableDraggingPosition.current = true; + return draggingPosition; + }, + get CustomDragPreview() { + enableCustomDragPreview.current = true; + return customDragPreviewPortal; + }, + get dropTarget() { + enableDropTarget.current = true; + return dropTarget; + }, + dragRef, + dragHandleRef, + }; +}; diff --git a/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts b/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts new file mode 100644 index 000000000..5f5408f87 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts @@ -0,0 +1,166 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, style } from '@vanilla-extract/css'; + +export const terminalSize = createVar(); +export const horizontalIndent = createVar(); +export const indicatorColor = createVar(); + +export const treeLine = style({ + vars: { + [terminalSize]: '8px', + }, + // To make things a bit clearer we are making the box that the indicator in as + // big as the whole tree item + position: 'absolute', + top: 0, + right: 0, + left: horizontalIndent, + bottom: 0, + + // We don't want to cause any additional 'dragenter' events + pointerEvents: 'none', + + // Terminal + '::before': { + display: 'block', + content: '""', + position: 'absolute', + zIndex: 2, + + boxSizing: 'border-box', + width: terminalSize, + height: terminalSize, + left: 0, + background: 'transparent', + borderColor: indicatorColor, + borderWidth: 2, + borderRadius: '50%', + borderStyle: 'solid', + }, + + // Line + '::after': { + display: 'block', + content: '""', + position: 'absolute', + zIndex: 1, + background: indicatorColor, + left: `calc(${terminalSize} / 2)`, // putting the line to the right of the terminal + height: 2, + right: 0, + }, +}); + +export const lineAboveStyles = style({ + // terminal + '::before': { + top: 0, + // move to position to be a 'cap' on the line + transform: `translate(calc(-0.5 * ${terminalSize}), calc(-0.5 * ${terminalSize}))`, + }, + // line + '::after': { + top: `${-0.5 * 2}px`, + }, +}); + +export const lineBelowStyles = style({ + '::before': { + bottom: 0, + // move to position to be a 'cap' on the line + transform: `translate(calc(-0.5 * ${terminalSize}), calc(0.5 * ${terminalSize}))`, + }, + // line + '::after': { + bottom: `${-0.5 * 2}px`, + }, +}); + +export const outlineStyles = style({ + // To make things a bit clearer we are making the box that the indicator in as + // big as the whole tree item + position: 'absolute', + top: 0, + right: 0, + left: horizontalIndent, + bottom: 0, + + // We don't want to cause any additional 'dragenter' events + pointerEvents: 'none', + + border: `2px solid ${indicatorColor}`, + // TODO: make this a prop? + // For now: matching the Confluence tree item border radius + borderRadius: '3px', +}); + +export const horizontal = style({ + height: 2, + left: `calc(${terminalSize}/2)`, + right: 0, + '::before': { + // Horizontal indicators have the terminal on the left + left: `calc(-${terminalSize})`, + }, +}); + +export const vertical = style({ + width: 2, + top: `calc(${terminalSize}/2)`, + bottom: 0, + '::before': { + // Vertical indicators have the terminal at the top + top: `calc(-1 * ${terminalSize})`, + }, +}); + +export const localLineOffset = createVar(); + +export const top = style({ + top: localLineOffset, + '::before': { + top: `calc(-1 * ${terminalSize} + 1px)`, + }, +}); +export const right = style({ + right: localLineOffset, + '::before': { + right: `calc(-1 * ${terminalSize} + 1px)`, + }, +}); +export const bottom = style({ + bottom: localLineOffset, + '::before': { + bottom: `calc(-1 * ${terminalSize} + 1px)`, + }, +}); +export const left = style({ + left: localLineOffset, + '::before': { + left: `calc(-1 * ${terminalSize} + 1px)`, + }, +}); + +export const edgeLine = style({ + vars: { + [terminalSize]: '8px', + }, + display: 'block', + position: 'absolute', + zIndex: 1, + // Blocking pointer events to prevent the line from triggering drag events + // Dragging over the line should count as dragging over the element behind it + pointerEvents: 'none', + background: cssVar('--affine-primary-color'), + + // Terminal + '::before': { + content: '""', + width: terminalSize, + height: terminalSize, + boxSizing: 'border-box', + position: 'absolute', + border: `${terminalSize} solid ${cssVar('--affine-primary-color')}`, + borderRadius: '50%', + }, +}); diff --git a/packages/frontend/component/src/ui/dnd/drop-indicator.tsx b/packages/frontend/component/src/ui/dnd/drop-indicator.tsx new file mode 100644 index 000000000..481bc44d6 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/drop-indicator.tsx @@ -0,0 +1,124 @@ +/** @jsx jsx */ + +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { cssVar } from '@toeverything/theme'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; +import { type ReactElement } from 'react'; + +import * as styles from './drop-indicator.css'; + +export type DropIndicatorProps = { + instruction?: Instruction | null; + edge?: Edge | null; +}; + +function getTreeElement({ + instruction, + isBlocked, +}: { + instruction: Exclude; + isBlocked: boolean; +}): ReactElement | null { + const style = { + [styles.horizontalIndent]: `${instruction.currentLevel * instruction.indentPerLevel}px`, + [styles.indicatorColor]: !isBlocked + ? cssVar('--affine-primary-color') + : cssVar('--affine-warning-color'), + }; + + if (instruction.type === 'reorder-above') { + return ( +
+ ); + } + if (instruction.type === 'reorder-below') { + return ( +
+ ); + } + + if (instruction.type === 'make-child') { + return ( +
+ ); + } + + if (instruction.type === 'reparent') { + style[styles.horizontalIndent] = `${ + instruction.desiredLevel * instruction.indentPerLevel + }px`; + + return ( +
+ ); + } + return null; +} + +type Orientation = 'horizontal' | 'vertical'; + +const edgeToOrientationMap: Record = { + top: 'horizontal', + bottom: 'horizontal', + left: 'vertical', + right: 'vertical', +}; + +const orientationStyles: Record = { + horizontal: styles.horizontal, + vertical: styles.vertical, +}; + +const edgeStyles: Record = { + top: styles.top, + bottom: styles.bottom, + left: styles.left, + right: styles.right, +}; + +function getEdgeElement(edge: Edge, gap: number = 0) { + const lineOffset = `calc(-0.5 * (${gap}px + 2px))`; + + const orientation = edgeToOrientationMap[edge]; + + return ( +
+ ); +} + +export function DropIndicator({ instruction, edge }: DropIndicatorProps) { + if (edge) { + return getEdgeElement(edge, 0); + } + if (instruction) { + if (instruction.type === 'instruction-blocked') { + return getTreeElement({ + instruction: instruction.desired, + isBlocked: true, + }); + } + return getTreeElement({ instruction, isBlocked: false }); + } + return; +} diff --git a/packages/frontend/component/src/ui/dnd/drop-target.ts b/packages/frontend/component/src/ui/dnd/drop-target.ts new file mode 100644 index 000000000..6f1d04440 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/drop-target.ts @@ -0,0 +1,195 @@ +import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { + attachClosestEdge, + type Edge, + extractClosestEdge, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { + attachInstruction, + extractInstruction, + type Instruction, + type ItemMode, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { DNDData } from './types'; + +type DropTargetGetFeedback = Parameters< + NonNullable[0]['canDrop']> +>[0] & { + source: { + data: D['draggable']; + }; +}; + +type DropTargetGet = + | T + | ((data: DropTargetGetFeedback) => T); + +function dropTargetGet( + get: T +): T extends undefined + ? undefined + : T extends DropTargetGet + ? (args: DropTargetGetFeedback) => I + : never { + if (get === undefined) { + return undefined as any; + } + return ((args: DropTargetGetFeedback) => + typeof get === 'function' ? (get as any)(args) : get) as any; +} + +export type DropTargetDropEvent = Parameters< + NonNullable[0]['onDrop']> +>[0] & { treeInstruction: Instruction | null; closestEdge: Edge | null } & { + source: { data: D['draggable'] }; +}; + +export type DropTargetDragEvent = Parameters< + NonNullable[0]['onDrag']> +>[0] & { treeInstruction: Instruction | null; closestEdge: Edge | null } & { + source: { data: D['draggable'] }; +}; + +export interface DropTargetOptions { + data?: DropTargetGet; + canDrop?: DropTargetGet; + dropEffect?: DropTargetGet<'copy' | 'link' | 'move', D>; + isSticky?: DropTargetGet; + treeInstruction?: { + block?: Instruction['type'][]; + mode: ItemMode; + currentLevel: number; + indentPerLevel: number; + }; + closestEdge?: { + allowedEdges: Edge[]; + }; + onDrop?: (data: DropTargetDropEvent) => void; + onDrag?: (data: DropTargetDragEvent) => void; +} + +export const useDropTarget = ( + getOptions: () => DropTargetOptions = () => ({}), + deps: any[] = [] +) => { + const dropTargetRef = useRef(null); + const [draggedOver, setDraggedOver] = useState(false); + const [treeInstruction, setTreeInstruction] = useState( + null + ); + const [closestEdge, setClosestEdge] = useState(null); + + const enableDraggedOver = useRef(false); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const options = useMemo(getOptions, deps); + + useEffect(() => { + if (!dropTargetRef.current) { + return; + } + return dropTargetForElements({ + element: dropTargetRef.current, + canDrop: dropTargetGet(options.canDrop), + getDropEffect: dropTargetGet(options.dropEffect), + getIsSticky: dropTargetGet(options.isSticky), + onDrop: args => { + if (enableDraggedOver.current) { + setDraggedOver(false); + } + if (options.treeInstruction) { + setTreeInstruction(null); + } + if (options.closestEdge) { + setClosestEdge(null); + } + if (dropTargetRef.current) { + delete dropTargetRef.current.dataset['draggedOver']; + } + if ( + args.location.current.dropTargets[0]?.element === + dropTargetRef.current + ) { + options.onDrop?.({ + ...args, + treeInstruction: extractInstruction(args.self.data), + closestEdge: extractClosestEdge(args.self.data), + } as DropTargetDropEvent); + } + }, + getData: args => { + const originData = dropTargetGet(options.data ?? {})(args); + const { input, element } = args; + const withInstruction = options.treeInstruction + ? attachInstruction(originData, { + input, + element, + currentLevel: options.treeInstruction.currentLevel, + indentPerLevel: options.treeInstruction.indentPerLevel, + mode: options.treeInstruction.mode, + block: options.treeInstruction.block, + }) + : originData; + const withClosestEdge = options.closestEdge + ? attachClosestEdge(withInstruction, { + element, + input, + allowedEdges: options.closestEdge.allowedEdges, + }) + : withInstruction; + return withClosestEdge; + }, + onDragEnter: () => { + if (enableDraggedOver.current) { + setDraggedOver(true); + } + if (dropTargetRef.current) { + dropTargetRef.current.dataset['draggedOver'] = 'true'; + } + }, + onDrag: args => { + let instruction = null; + let closestEdge = null; + if (options.treeInstruction) { + instruction = extractInstruction(args.self.data); + setTreeInstruction(instruction); + } + if (options.closestEdge) { + closestEdge = extractClosestEdge(args.self.data); + setClosestEdge(closestEdge); + } + options.onDrag?.({ + ...args, + treeInstruction: instruction, + closestEdge, + } as DropTargetDropEvent); + }, + onDragLeave: () => { + if (enableDraggedOver.current) { + setDraggedOver(false); + } + if (options.treeInstruction) { + setTreeInstruction(null); + } + if (options.closestEdge) { + setClosestEdge(null); + } + if (dropTargetRef.current) { + delete dropTargetRef.current.dataset['draggedOver']; + } + }, + }); + }, [options]); + + return { + dropTargetRef, + get draggedOver() { + enableDraggedOver.current = true; + return draggedOver; + }, + treeInstruction, + closestEdge, + }; +}; diff --git a/packages/frontend/component/src/ui/dnd/index.ts b/packages/frontend/component/src/ui/dnd/index.ts new file mode 100644 index 000000000..b56004fe3 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/index.ts @@ -0,0 +1,4 @@ +export * from './draggable'; +export * from './drop-indicator'; +export * from './drop-target'; +export * from './types'; diff --git a/packages/frontend/component/src/ui/dnd/types.ts b/packages/frontend/component/src/ui/dnd/types.ts new file mode 100644 index 000000000..7300c4753 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/types.ts @@ -0,0 +1,7 @@ +export interface DNDData< + Draggable extends Record = Record, + DropTarget extends Record = Record, +> { + draggable: Draggable; + dropTarget: DropTarget; +} diff --git a/yarn.lock b/yarn.lock index 08979d704..9416eae35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -282,6 +282,8 @@ __metadata: "@affine/electron-api": "workspace:*" "@affine/graphql": "workspace:*" "@affine/i18n": "workspace:*" + "@atlaskit/pragmatic-drag-and-drop": "npm:^1.2.1" + "@atlaskit/pragmatic-drag-and-drop-hitbox": "npm:^1.0.3" "@blocksuite/block-std": "npm:0.16.0-canary-202407050348-4620c21" "@blocksuite/blocks": "npm:0.16.0-canary-202407050348-4620c21" "@blocksuite/global": "npm:0.16.0-canary-202407050348-4620c21" @@ -355,7 +357,7 @@ __metadata: rxjs: "npm:^7.8.1" sonner: "npm:^1.4.41" storybook: "npm:^7.6.17" - storybook-dark-mode: "npm:4.0.2" + storybook-dark-mode: "npm:4.0.1" swr: "npm:^2.2.5" typescript: "npm:^5.4.5" uuid: "npm:^10.0.0" @@ -1161,6 +1163,27 @@ __metadata: languageName: node linkType: hard +"@atlaskit/pragmatic-drag-and-drop-hitbox@npm:^1.0.3": + version: 1.0.3 + resolution: "@atlaskit/pragmatic-drag-and-drop-hitbox@npm:1.0.3" + dependencies: + "@atlaskit/pragmatic-drag-and-drop": "npm:^1.1.0" + "@babel/runtime": "npm:^7.0.0" + checksum: 10/5aae13922b42ad70719749e0d0f3b7c45330ec141a2f54ae1773a4a73278c765a2e853eb2b475c7779d4d9797f26eaae68256ace3e83593e576462a31c2b39b2 + languageName: node + linkType: hard + +"@atlaskit/pragmatic-drag-and-drop@npm:^1.1.0, @atlaskit/pragmatic-drag-and-drop@npm:^1.2.1": + version: 1.2.1 + resolution: "@atlaskit/pragmatic-drag-and-drop@npm:1.2.1" + dependencies: + "@babel/runtime": "npm:^7.0.0" + bind-event-listener: "npm:^3.0.0" + raf-schd: "npm:^4.0.3" + checksum: 10/0dc7d3a6b67074ef74fc0eb59877c512eabfed23b575d26f1c6f562b20181b49d2ef7e46d6029cf8ac0ccc4f52790131cd6815edbebf9c6f75e2f4a62496cd05 + languageName: node + linkType: hard + "@aw-web-design/x-default-browser@npm:1.4.126": version: 1.4.126 resolution: "@aw-web-design/x-default-browser@npm:1.4.126" @@ -18030,6 +18053,13 @@ __metadata: languageName: node linkType: hard +"bind-event-listener@npm:^3.0.0": + version: 3.0.0 + resolution: "bind-event-listener@npm:3.0.0" + checksum: 10/3d442307ee906b79f041433b065e7b259bd1e5231a74519271cc5beb485f7c469609da9c7f20fdd0b3ab340e4691fee826b76d003c71cf5ad955186aba5c256e + languageName: node + linkType: hard + "bindings@npm:^1.4.0": version: 1.5.0 resolution: "bindings@npm:1.5.0" @@ -32630,6 +32660,13 @@ __metadata: languageName: node linkType: hard +"raf-schd@npm:^4.0.3": + version: 4.0.3 + resolution: "raf-schd@npm:4.0.3" + checksum: 10/45514041c5ad31fa96aef3bb3c572a843b92da2f2cd1cb4a47c9ad58e48761d3a4126e18daa32b2bfa0bc2551a42d8f324a0e40e536cb656969929602b4e8b58 + languageName: node + linkType: hard + "ramda@npm:0.29.0": version: 0.29.0 resolution: "ramda@npm:0.29.0" @@ -35245,9 +35282,9 @@ __metadata: languageName: node linkType: hard -"storybook-dark-mode@npm:4.0.2": - version: 4.0.2 - resolution: "storybook-dark-mode@npm:4.0.2" +"storybook-dark-mode@npm:4.0.1": + version: 4.0.1 + resolution: "storybook-dark-mode@npm:4.0.1" dependencies: "@storybook/components": "npm:^8.0.0" "@storybook/core-events": "npm:^8.0.0" @@ -35257,7 +35294,7 @@ __metadata: "@storybook/theming": "npm:^8.0.0" fast-deep-equal: "npm:^3.1.3" memoizerific: "npm:^1.11.3" - checksum: 10/c9ef7bc6734df7486ff763c9da3c69505269eaf5fd7b5b489553f023b363ea892862241e6d701ad647ca5d1e64fd9a2646b8985c7ea8ac97a3bca87891db6fe5 + checksum: 10/3225e5bdaba0ea76b65d642202d9712d7de234e3b5673fb46e444892ab114be207dd287778e2002b662ec35bb8153d2624ff280ce51c5299fb13c711431dad40 languageName: node linkType: hard