mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(component): new dnd api (#7467)
This commit is contained in:
@@ -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"
|
||||
|
||||
587
packages/frontend/component/src/ui/dnd/dnd.stories.tsx
Normal file
587
packages/frontend/component/src/ui/dnd/dnd.stories.tsx
Normal 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',
|
||||
};
|
||||
242
packages/frontend/component/src/ui/dnd/draggable.ts
Normal file
242
packages/frontend/component/src/ui/dnd/draggable.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
166
packages/frontend/component/src/ui/dnd/drop-indicator.css.ts
Normal file
166
packages/frontend/component/src/ui/dnd/drop-indicator.css.ts
Normal 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%',
|
||||
},
|
||||
});
|
||||
124
packages/frontend/component/src/ui/dnd/drop-indicator.tsx
Normal file
124
packages/frontend/component/src/ui/dnd/drop-indicator.tsx
Normal 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;
|
||||
}
|
||||
195
packages/frontend/component/src/ui/dnd/drop-target.ts
Normal file
195
packages/frontend/component/src/ui/dnd/drop-target.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
4
packages/frontend/component/src/ui/dnd/index.ts
Normal file
4
packages/frontend/component/src/ui/dnd/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './draggable';
|
||||
export * from './drop-indicator';
|
||||
export * from './drop-target';
|
||||
export * from './types';
|
||||
7
packages/frontend/component/src/ui/dnd/types.ts
Normal file
7
packages/frontend/component/src/ui/dnd/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user