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 (
+
+ );
+};
+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 (
+
+ );
+};
+
+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, { 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