<div id="root"></div>
const { useState, useRef, useCallback, useEffect, useLayoutEffect, forwardRef } = React;
const { createGlobalStyle } = styled;

/*** types ***/
type Point = {
	x: number;
	y: number;
};

type DraggingDirection = {
	horizontal: 'left' | 'right' | null;
	vertical: 'up' | 'down' | null;
};

type Axis = 'x' | 'y';

type MouseStatus = {
	isDown: boolean;
	isMove: boolean;
	isUp: boolean;
};

type DraggingElementStatus = {
	translate: Point;
	mouseStatus: MouseStatus;
	draggingElement: EventTarget & Element | null;
	draggingDirection: DraggingDirection;
};

type Handler = (e: React.MouseEvent<EventTarget & HTMLElement>) => void;

/*** hooks ***/
const useDraggableElements = (isStyleTransform: boolean = true): [
	DraggingElementStatus,
	Handler
] => {
	// ドラッグしている要素の移動量
	const [translate, setTranslate] = useState<Point>({
		x: 0,
		y: 0
	});
	
	// 現在のマウスイベントの状態
	const [mouseStatus, setMouseStatus] = useState<MouseStatus>({
		isDown: false,
		isMove: false,
		isUp: false
	});
	
	// マウスを押し込んだときのカーソルの座標
	const startPoint = useRef<Point>({ x: 0, y: 0 });
	
	// 前回のtranslate
	const currentTranslate = useRef<Point>({ x: 0, y: 0 });
	
	// 前回のマウスの移動距離
	const prevDifference = useRef<Point>({ x: 0, y: 0 });
	
	// ドラッグしている要素
	const draggingElement = useRef<EventTarget & HTMLElement | null>(null);

	// ドラッグしている方向
	const draggingDirection = useRef<DraggingDirection>({
		horizontal: null,
		vertical: null,
	});
	
	// .draggableが追加されていない要素がドラッグされないようにする
	const isDraggable = (): boolean => draggingElement.current ? draggingElement.current.classList.contains('draggable') : false;
	
	// mousedownが発生したときに実行する関数
	const handleDown = useCallback((e: React.MouseEvent<EventTarget & HTMLElement>): void => {
		// 押し込んだ要素を取得
		draggingElement.current = e.currentTarget;
		
		//ドラッグした要素に.draggableクラスが指定されていなければ終了
		if(!isDraggable()) return;
		
		// 押し込んだ要素のstyleから現在のtransform: translate()のx, y値を取得する
		const matrix = new DOMMatrix(getComputedStyle(draggingElement.current).transform);
		currentTranslate.current = {
			x: matrix.translateSelf().e,
			y: matrix.translateSelf().f
		};
	
		// 一旦すべてのdraggableな要素のz-indexを1000に戻してから押し込んだ要素のz-indexを10001にする
		// z-indexはpositionプロパティの値が指定されていないと適用されない
		const draggableElements = document.getElementsByClassName("draggable") as HTMLCollectionOf<HTMLElement>;
		for(let i = 0; i < draggableElements.length; i++) {
			draggableElements[i].style.zIndex = `1000`;
		}		
		draggingElement.current.style.position = 'relative';
		draggingElement.current.style.zIndex = `1001`;

		// 押し込んだときのページの左上(0, 0)からのカーソルの座標
		const x = e.pageX;
		const y = e.pageY;
		startPoint.current = { x, y };
		
		// 押し込んでいることを示すisDownをtrueに切り替える
		setMouseStatus(prevMouseStatus => ({
			...prevMouseStatus,
			isUp: false,
			isDown: true
		}));
	}, []);
	
	// mousemoveが発生したときに実行する関数
	const handleMove = (e: MouseEvent): void => {
		// 押し込んでいなければ終了
		if(!draggingElement.current) return;
		
		//ドラッグした要素に.draggableクラスが指定されていなければ終了
		if(!isDraggable()) return;
		
		// テキストをdraggableにした場合に、ドラッグしたときにテキストが選択されないようにする
		e.preventDefault();
		console.log('defaultPrevented: ', e.defaultPrevented);
		
		console.log('mousemove');
		
		// 押し込んだところから進んだカーソルの距離
		const differenceX = e.pageX - startPoint.current.x;
		const differenceY = e.pageY - startPoint.current.y;
		
		// ドラッグしている方向
		if (differenceX > prevDifference.current.x) {
			draggingDirection.current.horizontal = "right";
		}
		else if (differenceX < prevDifference.current.x) {
			draggingDirection.current.horizontal = "left";
		}

		if (differenceY > prevDifference.current.y) {
			draggingDirection.current.vertical = "down";
		}
		else if (differenceY < prevDifference.current.y) {
			draggingDirection.current.vertical = "up";
		}
		
		console.log('directionX: ', draggingDirection.current.horizontal);
		console.log('directionY: ', draggingDirection.current.vertical);

		setTranslate({
			x: currentTranslate.current.x + differenceX,
			y: currentTranslate.current.y + differenceY
		});
		
		// 前回までに進んだ距離として保存しておく
		prevDifference.current = {
			x: differenceX,
			y: differenceY
		};
		
		// 押し込んだまま動かしていることを示すisMoveをtrueに切り替える
		setMouseStatus(prevMouseStatus => ({
			...prevMouseStatus,
			isMove: true
		}));
	};

	// mouseupが発生したときに実行する関数
	const handleUp = (e: MouseEvent): void => {
		// 押し込んでいなければ終了
		if(!draggingElement.current) return;
		
		//ドラッグした要素に.draggableクラスが指定されていなければ終了
		if(!isDraggable()) return;
		console.log('mouseup');
		
		// ドロップ=押し込みをやめたということで空にする
		draggingElement.current = null;
		
		// 押し込みをやめたことを示すisUpをtrueに切り替え、isDownとisMoveをfalseに戻す
		setMouseStatus(prevMouseStatus => ({
			...prevMouseStatus,
			isDown: false,
			isMove: false,
			isUp: true
		}));
	};
	
	// translateが変化したときに要素を動かす
	useEffect(() => {
		// isStyleTransformがfalseであればstyle.transformを指定しない
		if(!isStyleTransform) return;
		
		// nullの判定(TypeScript)
		if(!draggingElement.current) return
		
		// style.transformで要素を動かす
		draggingElement.current.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`;
	}, [translate]);

	// mousemove, mouseup, mouseleaveイベントが発生したときに実行されるようにする
	// 初回のレンダー後に一度だけ実行
	useEffect(() => {
		document.body.addEventListener("mousemove", handleMove);
		document.body.addEventListener("mouseup", handleUp);
		document.body.addEventListener("mouseleave", handleUp);
		
		return () => {
			document.body.removeEventListener("mousemove", handleMove);
			document.body.removeEventListener("mouseup", handleUp);
			document.body.removeEventListener("mouseleave", handleUp);
		};
	}, []);

	return [
		{
			translate,
			mouseStatus,
			draggingElement: draggingElement.current,
			draggingDirection: draggingDirection.current
		},
		handleDown
	];
};

/*** components ***/
const GlobalStyle = createGlobalStyle`
  :root {
    --bg-color: #fff;
    --text-color: rgb(40, 40, 40);
  }
  
  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  body {
    background-color: var(--bg-color);
    color: var(--text-color);
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
  }
`;

const StyledApp = styled.div`
	display: flex;
	
	.dragging-element-status {
		position: fixed;
		top: 0;
		left: 0;
	}
	
	.element {
		&-1 {
			background-color: red;
		}
		&-2 {
			background-color: blue;
		}
	}
`;

const Draggable = styled.div`
	background-color: gray;
	height: 100px;	
	width: 100px;
`;

const App = () => {
	const [draggingElementStatus, handleDown] = useDraggableElements();	

	return (
		<>
			<GlobalStyle />
			<StyledApp>
				<div className="container">
					<div className="dragging-element-status">
						<div className="dragging-element">{`draggingElement: ${draggingElementStatus.draggingElement && draggingElementStatus.draggingElement.id}`}</div>
						<div className="translate draggable" onMouseDown={handleDown}>{`x: ${draggingElementStatus.translate.x}, y: ${draggingElementStatus.translate.y}`}</div>
						<div className="dragging-direction"> 
							{`horizontal: ${draggingElementStatus.draggingDirection.horizontal}, vertical: ${draggingElementStatus.draggingDirection.vertical}`}
						</div>
						<div className="mouse-status"> 
							{
							`isDown: ${draggingElementStatus.mouseStatus.isDown}, isMove: ${draggingElementStatus.mouseStatus.isMove}, isUp: ${draggingElementStatus.mouseStatus.isUp}`
							}
						</div>
					</div>
					<div className="draggables">
						<Draggable
							id="element-1"
							className="element-1 draggable"
							onMouseDown={handleDown}
						/>
						<Draggable
							id="element-2"
							className="element-2 draggable"
							onMouseDown={handleDown}
						/>
					</div>
				</div>
			</StyledApp>
		</>
	);
};

ReactDOM.render(<App />, document.getElementById("root"));
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/styled-components/4.3.1/styled-components.min.js