mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08:00
feat(component): new dnd api (#7467)
This commit is contained in:
+1
-1
@@ -247,7 +247,7 @@ const config = {
|
|||||||
'react-hooks/exhaustive-deps': [
|
'react-hooks/exhaustive-deps': [
|
||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
additionalHooks: 'useAsyncCallback',
|
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"@affine/electron-api": "workspace:*",
|
"@affine/electron-api": "workspace:*",
|
||||||
"@affine/graphql": "workspace:*",
|
"@affine/graphql": "workspace:*",
|
||||||
"@affine/i18n": "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/core": "^6.1.0",
|
||||||
"@dnd-kit/modifiers": "^7.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
"@vanilla-extract/css": "^1.14.2",
|
"@vanilla-extract/css": "^1.14.2",
|
||||||
"fake-indexeddb": "^6.0.0",
|
"fake-indexeddb": "^6.0.0",
|
||||||
"storybook": "^7.6.17",
|
"storybook": "^7.6.17",
|
||||||
"storybook-dark-mode": "4.0.2",
|
"storybook-dark-mode": "4.0.1",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vitest": "1.6.0"
|
"vitest": "1.6.0"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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%',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './draggable';
|
||||||
|
export * from './drop-indicator';
|
||||||
|
export * from './drop-target';
|
||||||
|
export * from './types';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -282,6 +282,8 @@ __metadata:
|
|||||||
"@affine/electron-api": "workspace:*"
|
"@affine/electron-api": "workspace:*"
|
||||||
"@affine/graphql": "workspace:*"
|
"@affine/graphql": "workspace:*"
|
||||||
"@affine/i18n": "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/block-std": "npm:0.16.0-canary-202407050348-4620c21"
|
||||||
"@blocksuite/blocks": "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"
|
"@blocksuite/global": "npm:0.16.0-canary-202407050348-4620c21"
|
||||||
@@ -355,7 +357,7 @@ __metadata:
|
|||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
sonner: "npm:^1.4.41"
|
sonner: "npm:^1.4.41"
|
||||||
storybook: "npm:^7.6.17"
|
storybook: "npm:^7.6.17"
|
||||||
storybook-dark-mode: "npm:4.0.2"
|
storybook-dark-mode: "npm:4.0.1"
|
||||||
swr: "npm:^2.2.5"
|
swr: "npm:^2.2.5"
|
||||||
typescript: "npm:^5.4.5"
|
typescript: "npm:^5.4.5"
|
||||||
uuid: "npm:^10.0.0"
|
uuid: "npm:^10.0.0"
|
||||||
@@ -1161,6 +1163,27 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@aw-web-design/x-default-browser@npm:1.4.126":
|
||||||
version: 1.4.126
|
version: 1.4.126
|
||||||
resolution: "@aw-web-design/x-default-browser@npm:1.4.126"
|
resolution: "@aw-web-design/x-default-browser@npm:1.4.126"
|
||||||
@@ -18030,6 +18053,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"bindings@npm:^1.4.0":
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
resolution: "bindings@npm:1.5.0"
|
resolution: "bindings@npm:1.5.0"
|
||||||
@@ -32630,6 +32660,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ramda@npm:0.29.0":
|
||||||
version: 0.29.0
|
version: 0.29.0
|
||||||
resolution: "ramda@npm:0.29.0"
|
resolution: "ramda@npm:0.29.0"
|
||||||
@@ -35245,9 +35282,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"storybook-dark-mode@npm:4.0.2":
|
"storybook-dark-mode@npm:4.0.1":
|
||||||
version: 4.0.2
|
version: 4.0.1
|
||||||
resolution: "storybook-dark-mode@npm:4.0.2"
|
resolution: "storybook-dark-mode@npm:4.0.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@storybook/components": "npm:^8.0.0"
|
"@storybook/components": "npm:^8.0.0"
|
||||||
"@storybook/core-events": "npm:^8.0.0"
|
"@storybook/core-events": "npm:^8.0.0"
|
||||||
@@ -35257,7 +35294,7 @@ __metadata:
|
|||||||
"@storybook/theming": "npm:^8.0.0"
|
"@storybook/theming": "npm:^8.0.0"
|
||||||
fast-deep-equal: "npm:^3.1.3"
|
fast-deep-equal: "npm:^3.1.3"
|
||||||
memoizerific: "npm:^1.11.3"
|
memoizerific: "npm:^1.11.3"
|
||||||
checksum: 10/c9ef7bc6734df7486ff763c9da3c69505269eaf5fd7b5b489553f023b363ea892862241e6d701ad647ca5d1e64fd9a2646b8985c7ea8ac97a3bca87891db6fe5
|
checksum: 10/3225e5bdaba0ea76b65d642202d9712d7de234e3b5673fb46e444892ab114be207dd287778e2002b662ec35bb8153d2624ff280ce51c5299fb13c711431dad40
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user