Skip to content

Instantly share code, notes, and snippets.

@symant233
Last active January 15, 2026 14:08
Show Gist options
  • Select an option

  • Save symant233/22306e9a200a2f48999748bbbc34ed92 to your computer and use it in GitHub Desktop.

Select an option

Save symant233/22306e9a200a2f48999748bbbc34ed92 to your computer and use it in GitHub Desktop.
DragFlow pure component, using grid layout and DOM draggable
import './index.less';
import React, { useRef } from 'react';
import cs from 'classnames';
export interface DragFlowProps<T> {
data: T[];
renderItem: (item: T, index?: number) => React.ReactNode;
/**
* `onSortEnd` 拖拽结束触发,将排序结果传入该函数
*/
onSortEnd?: (data: T[]) => void;
/**
* `itemBasis` 限定元素的最小宽度 pixel, 不提供则撑占全部空间
*/
itemBasis?: number;
/**
* `containerClass` 为拖拽容器增加的额外类名,可用于样式自定义
*/
containerClass?: string;
/**
* `itemClass` 为拖拽元素增加的额外类名,可用于样式自定义
*/
itemClass?: string;
/**
* `itemGap` 定义排列的间距大小
*/
itemGap?: number;
onDragStart?: (e: React.DragEvent<HTMLElement>) => void;
}
export enum FlowClass {
CONTAINER = 'dragflow-container',
ITEM = 'dragflow-item',
DRAGGING = 'dragflow-dragging',
}
/**
* 部分重要参数说明 (其余参数参考 `DragFlowProps`)
* - `data: T[];` 渲染每个元素的数据数组(即 `renderItem` 的参数数组)
* - `renderItem: (item: T, index?: number) => React.ReactNode;` 渲染元素的组件
* - `itemBasis?: number;` 元素的最小宽度 pixel,不提供则撑占全部空间,成为纵向列表
*/
const DragFlow = (props: DragFlowProps<any>) => {
const { data, renderItem, itemBasis } = props;
const containerRef = useRef<HTMLDivElement>(null);
function onDragStart(e: React.DragEvent<HTMLElement>) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setDragImage(new Image(), 0, 0);
(e.target as HTMLElement).classList.add(FlowClass.DRAGGING);
props?.onDragStart?.(e);
}
function onDragEnd(e: React.DragEvent<HTMLElement>) {
(e.target as HTMLDivElement).classList.remove(FlowClass.DRAGGING);
if (props?.onSortEnd) {
try {
const items = Array.from(
containerRef.current.querySelectorAll(`.${FlowClass.ITEM}`),
);
const dataIndices = items.map(item =>
parseInt((item as HTMLElement).dataset?.index, 10),
);
const result = dataIndices.map(index => data[index]);
props.onSortEnd(result);
} catch (err) {
console.error(err);
}
}
}
function onDragOver(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!containerRef.current) {
return;
}
const draggingItem = containerRef.current.querySelector(`.${FlowClass.DRAGGING}`);
const items = Array.from(
containerRef.current.querySelectorAll(`.${FlowClass.ITEM}`),
);
const current = items.find(item => {
return e.target === item || item.contains(e.target as Node);
}) as HTMLElement;
if (!current || !draggingItem || current === draggingItem) {
return;
}
if (current.nextElementSibling === draggingItem) {
containerRef.current.insertBefore(draggingItem, current);
} else {
if (current.offsetTop < (draggingItem as HTMLElement).offsetTop) {
containerRef.current.insertBefore(draggingItem, current);
} else {
containerRef.current.insertBefore(
draggingItem,
current.nextElementSibling,
);
}
}
}
return (
<div
className={cs(FlowClass.CONTAINER, props?.containerClass)}
style={
{
'--dragflow-item-basis': itemBasis ? `${itemBasis}px` : '1fr',
gridGap: props?.itemGap && `${props.itemGap}px`,
} as React.CSSProperties
}
ref={containerRef}
onDragOver={onDragOver}
onDragEnter={e => e.preventDefault()}
>
{data.map((item, index) => {
return (
<div
className={cs(FlowClass.ITEM, props?.itemClass)}
key={index}
data-index={index}
draggable={true}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{renderItem(item, index)}
</div>
);
})}
</div>
);
};
export default React.memo(DragFlow);
.dragflow-container {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(var(--dragflow-item-basis), 1fr));
grid-auto-rows: auto;
grid-gap: 4px;
width: 100%;
overflow: hidden;
.dragflow-item[draggable="true"] {
cursor: grab;
&:active {
cursor: grabbing;
}
}
.dragflow-dragging {
opacity: 0.6;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment