feat(component): new dnd api (#7467)

This commit is contained in:
EYHN
2024-07-15 04:00:41 +00:00
parent dca88e24fe
commit 7103b2e594
10 changed files with 1371 additions and 7 deletions

View File

@@ -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"

View File

@@ -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 (
<div>
<style>
{`.draggable[data-dragging='true'] {
opacity: 0.3;
}`}
</style>
<div className="draggable" ref={dragRef}>
Drag here
</div>
</div>
);
};
Draggable.args = {
canDrag: true,
disableDragPreview: false,
};
export const DraggableCustomPreview: StoryFn = () => {
const { dragRef, CustomDragPreview } = useDraggable(() => ({}), []);
return (
<div>
<div ref={dragRef}>Drag here</div>
<CustomDragPreview>
<div>Dragging🤌</div>
</CustomDragPreview>
</div>
);
};
export const DraggableControlledPreview: StoryFn = () => {
const { dragRef, draggingPosition } = useDraggable(
() => ({
disableDragPreview: true,
}),
[]
);
return (
<div>
<div
ref={dragRef}
style={{
transform: `translate(${draggingPosition.offsetX}px, 0px)`,
}}
>
Drag here
</div>
</div>
);
};
export const DropTarget: StoryFn<{ canDrop: boolean }> = ({ canDrop }) => {
const [dropData, setDropData] = useState<string>('');
const { dragRef } = useDraggable(
() => ({
data: { text: 'hello' },
}),
[]
);
const { dropTargetRef } = useDropTarget(
() => ({
canDrop,
onDrop(data) {
setDropData(prev => prev + data.source.data.text);
},
}),
[canDrop]
);
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<style>
{`
.drop-target {
width: 100px;
height: 100px;
text-align: center;
border: 2px solid red;
}
.drop-target[data-dragged-over='true'] {
border: 2px solid green;
}`}
</style>
<div ref={dragRef}>👉 hello</div>
<div className="drop-target" ref={dropTargetRef}>
{dropData || 'Drop here'}
</div>
</div>
);
};
DropTarget.args = {
canDrop: true,
};
const DropList = ({ children }: { children?: React.ReactNode }) => {
const [dropData, setDropData] = useState<string[]>([]);
const { dropTargetRef, draggedOver } = useDropTarget<
DNDData<{ text: string }>
>(
() => ({
onDrop(data) {
setDropData(prev => [...prev, data.source.data.text]);
},
}),
[]
);
return (
<ul style={{ padding: '20px' }} ref={dropTargetRef}>
<li>Append here{draggedOver && ' [dragged-over]'}</li>
{dropData.map((text, i) => (
<li key={i}>{text}</li>
))}
{children}
</ul>
);
};
export const NestedDropTarget: StoryFn<{ canDrop: boolean }> = () => {
const { dragRef } = useDraggable(
() => ({
data: { text: 'hello' },
}),
[]
);
return (
<div>
<div ref={dragRef}>👉 hello</div>
<br />
<ul>
<DropList>
<DropList>
<DropList></DropList>
</DropList>
</DropList>
</ul>
</div>
);
};
NestedDropTarget.args = {
canDrop: true,
};
export const DynamicDragPreview = () => {
type DataType = DNDData<Record<string, never>, { type: 'big' | 'small' }>;
const { dragRef, dragging, draggingPosition, dropTarget, CustomDragPreview } =
useDraggable<DataType>(() => ({}), []);
const { dropTargetRef: bigDropTargetRef } = useDropTarget<DataType>(
() => ({
data: { type: 'big' },
}),
[]
);
const { dropTargetRef: smallDropTargetRef } = useDropTarget<DataType>(
() => ({
data: { type: 'small' },
}),
[]
);
return (
<div
style={{
display: 'flex',
margin: '0 auto',
width: '600px',
border: '3px solid red',
flexWrap: 'wrap',
padding: '8px',
}}
>
<div
ref={dragRef}
style={{
padding: '10px',
border: '1px solid blue',
transform: `${dropTarget.length > 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
</div>
<div
ref={bigDropTargetRef}
style={{
width: '100%',
border: '1px solid green',
height: '100px',
fontSize: '50px',
}}
>
Big
</div>
<div
ref={smallDropTargetRef}
style={{
width: '100%',
border: '1px solid green',
height: '100px',
fontSize: '50px',
}}
>
Small
</div>
<CustomDragPreview position="pointer-outside">
<div
style={{
background: 'rgba(0, 0, 0, 0.1)',
borderRadius: '5px',
padding: '2px 6px',
}}
>
👋 this is a record
</div>
</CustomDragPreview>
</div>
);
};
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 (
<div
ref={node => {
dropTargetRef.current = node;
dragRef.current = node;
}}
style={{
position: 'relative',
padding: '10px',
border: '1px solid black',
}}
>
Item {id}
<DropIndicator edge={closestEdge} />
</div>
);
};
export const ReorderableList: StoryFn<{
orientation: 'horizontal' | 'vertical';
}> = ({ orientation }) => {
const [items, setItems] = useState<string[]>(['A', 'B', 'C']);
return (
<div
style={{
display: 'flex',
flexDirection: orientation === 'horizontal' ? 'row' : 'column',
}}
>
{items.map((item, i) => (
<ReorderableListItem
key={i}
id={item}
orientation={orientation}
onDrop={data => {
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);
}}
/>
))}
</div>
);
};
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<DNDData<{ node: Node }>> & {
dropAt: Node;
}
) => void;
isLastInGroup: boolean;
}) => {
const [expanded, setExpanded] = useState<boolean>(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 (
<>
<div
ref={node => {
dropTargetRef.current = node;
dragRef.current = node;
}}
style={{
paddingLeft: level * 20,
position: 'relative',
}}
>
<span onClick={() => setExpanded(prev => !prev)}>
{node.leaf ? '📃 ' : expanded ? '📂 ' : '📁 '}
</span>
{node.id}
<DropIndicator instruction={treeInstruction} />
</div>
{expanded &&
!dragging &&
node.children.map((child, i) => (
<ReorderableTreeNode
key={child.id}
level={level + 1}
isLastInGroup={i === node.children.length - 1}
node={child}
onDrop={onDrop}
/>
))}
</>
);
};
export const ReorderableTree: StoryFn = () => {
const [tree, setTree] = useState<Node>({
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<DNDData<{ node: Node }>> & {
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 (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<ReorderableTreeNode
isLastInGroup={true}
level={0}
node={tree}
onDrop={handleDrop}
/>
</div>
);
};
ReorderableList.argTypes = {
orientation: {
type: {
name: 'enum',
value: ['horizontal', 'vertical'],
required: true,
},
},
};
ReorderableList.args = {
orientation: 'vertical',
};

View File

@@ -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<Parameters<typeof draggable>[0]['getInitialData']>
>[0];
type DraggableGet<T> = T | ((data: DraggableGetFeedback) => T);
function draggableGet<T>(
get: T
): T extends undefined
? undefined
: T extends DraggableGet<infer I>
? (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<D extends DNDData = DNDData> {
data?: DraggableGet<D['draggable']>;
dataForExternal?: DraggableGet<{
[Key in
| 'text/uri-list'
| 'text/plain'
| 'text/html'
| 'Files'
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {})]?: string;
}>;
canDrag?: DraggableGet<boolean>;
disableDragPreview?: boolean;
}
export type DraggableCustomDragPreviewProps = React.PropsWithChildren<{
position?: 'pointer-outside' | 'pointer-center' | 'native';
}>;
export const useDraggable = <D extends DNDData = DNDData>(
getOptions: () => DraggableOptions<D> = () => ({}),
deps: any[] = []
) => {
const [dragging, setDragging] = useState<boolean>(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<DraggableCustomDragPreviewProps>
>(() => () => null);
const dragRef = useRef<any>(null);
const dragHandleRef = useRef<any>(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,
};
};

View File

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

View File

@@ -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<Instruction, { type: 'instruction-blocked' }>;
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 (
<div
className={clsx(styles.treeLine, styles.lineAboveStyles)}
style={assignInlineVars(style)}
/>
);
}
if (instruction.type === 'reorder-below') {
return (
<div
className={clsx(styles.treeLine, styles.lineBelowStyles)}
style={assignInlineVars(style)}
/>
);
}
if (instruction.type === 'make-child') {
return (
<div
className={clsx(styles.outlineStyles)}
style={assignInlineVars(style)}
/>
);
}
if (instruction.type === 'reparent') {
style[styles.horizontalIndent] = `${
instruction.desiredLevel * instruction.indentPerLevel
}px`;
return (
<div
className={clsx(styles.treeLine, styles.lineBelowStyles)}
style={assignInlineVars(style)}
/>
);
}
return null;
}
type Orientation = 'horizontal' | 'vertical';
const edgeToOrientationMap: Record<Edge, Orientation> = {
top: 'horizontal',
bottom: 'horizontal',
left: 'vertical',
right: 'vertical',
};
const orientationStyles: Record<Orientation, string> = {
horizontal: styles.horizontal,
vertical: styles.vertical,
};
const edgeStyles: Record<Edge, string> = {
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 (
<div
className={clsx([
styles.edgeLine,
orientationStyles[orientation],
edgeStyles[edge],
])}
style={assignInlineVars({ [styles.localLineOffset]: lineOffset })}
/>
);
}
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;
}

View File

@@ -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<D extends DNDData> = Parameters<
NonNullable<Parameters<typeof dropTargetForElements>[0]['canDrop']>
>[0] & {
source: {
data: D['draggable'];
};
};
type DropTargetGet<T, D extends DNDData> =
| T
| ((data: DropTargetGetFeedback<D>) => T);
function dropTargetGet<T, D extends DNDData>(
get: T
): T extends undefined
? undefined
: T extends DropTargetGet<infer I, D>
? (args: DropTargetGetFeedback<D>) => I
: never {
if (get === undefined) {
return undefined as any;
}
return ((args: DropTargetGetFeedback<D>) =>
typeof get === 'function' ? (get as any)(args) : get) as any;
}
export type DropTargetDropEvent<D extends DNDData> = Parameters<
NonNullable<Parameters<typeof dropTargetForElements>[0]['onDrop']>
>[0] & { treeInstruction: Instruction | null; closestEdge: Edge | null } & {
source: { data: D['draggable'] };
};
export type DropTargetDragEvent<D extends DNDData> = Parameters<
NonNullable<Parameters<typeof dropTargetForElements>[0]['onDrag']>
>[0] & { treeInstruction: Instruction | null; closestEdge: Edge | null } & {
source: { data: D['draggable'] };
};
export interface DropTargetOptions<D extends DNDData = DNDData> {
data?: DropTargetGet<D['dropTarget'], D>;
canDrop?: DropTargetGet<boolean, D>;
dropEffect?: DropTargetGet<'copy' | 'link' | 'move', D>;
isSticky?: DropTargetGet<boolean, D>;
treeInstruction?: {
block?: Instruction['type'][];
mode: ItemMode;
currentLevel: number;
indentPerLevel: number;
};
closestEdge?: {
allowedEdges: Edge[];
};
onDrop?: (data: DropTargetDropEvent<D>) => void;
onDrag?: (data: DropTargetDragEvent<D>) => void;
}
export const useDropTarget = <D extends DNDData = DNDData>(
getOptions: () => DropTargetOptions<D> = () => ({}),
deps: any[] = []
) => {
const dropTargetRef = useRef<any>(null);
const [draggedOver, setDraggedOver] = useState<boolean>(false);
const [treeInstruction, setTreeInstruction] = useState<Instruction | null>(
null
);
const [closestEdge, setClosestEdge] = useState<Edge | null>(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<D>);
}
},
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<D>);
},
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,
};
};

View File

@@ -0,0 +1,4 @@
export * from './draggable';
export * from './drop-indicator';
export * from './drop-target';
export * from './types';

View File

@@ -0,0 +1,7 @@
export interface DNDData<
Draggable extends Record<string, unknown> = Record<string, unknown>,
DropTarget extends Record<string, unknown> = Record<string, unknown>,
> {
draggable: Draggable;
dropTarget: DropTarget;
}