跳到主要内容

基于 Mouse Event API

2024年08月08日
柏拉文
越努力,越幸运

一、认识


Javascript本身是具有原生拖放功能的,但是由于兼容性问题,以及功能实现的方式,用的不是很广泛。Javascript动画广泛使用的还是模拟拖拽

二、细节


2.1 记录拖拽状态

定义 isDragging 来跟踪拖拽状态。状态逻辑如下:

  1. mousedown:当用户按下鼠标按钮时,设置 isDraggingtrue,并记录初始位置。

  2. mousemove:当用户移动鼠标时,如果 isDraggingtrue,更新元素的位置。

  3. mouseup:当用户释放鼠标按钮时,设置 isDraggingfalse

2.2 防止默认行为

mousedownmousemove 事件中调用 event.preventDefault(),以防止浏览器的默认行为(例如文本选择)。

2.3 阻止事件传播

mousedownmousemove 事件中调用 e.stopPropagation(),阻止事件传播

2.4 计算相对位置

1. 记录鼠标按下时,鼠标箭头在拖动元素中的位置

function computeMousedownXY(e, draggable) {
const rect = draggable.getBoundingClientRect();
const normalizedE = e.type.startsWith("mouse") ? e : e.targetTouches[0];

let x = normalizedE.pageX - rect.left - window.scrollX;
let y = normalizedE.pageY - rect.top - window.scrollY;
return [x, y];
}

2. 鼠标移动中,计算元素的新位置

function computeMousemoveXY({ e, downXY, container, draggable }) {
const containerRect = container.getBoundingClientRect();
const normalizedE = e.type.startsWith("mouse") ? e : e.targetTouches[0];
const x = normalizedE.pageX - downXY.x - containerRect.left;
const y = normalizedE.pageY - downXY.y - containerRect.top;
return [x, y];
}

2.5 限制拖拽范围

如果需要,可以在 mousemove 事件中添加逻辑,限制元素的拖拽范围,防止其移出容器边界。

// 限制拖拽范围的示例
function limitDrag(element, container) {
const rect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (rect.left < containerRect.left) {
element.style.left = '0px';
}
if (rect.top < containerRect.top) {
element.style.top = '0px';
}
if (rect.right > containerRect.right) {
element.style.left = `${containerRect.width - rect.width}px`;
}
if (rect.bottom > containerRect.bottom) {
element.style.top = `${containerRect.height - rect.height}px`;
}
}

或者

function limitDrag({ x, y, draggable, container }) {
const rect = draggable.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();

x = Math.max(Math.min(x, containerRect.width - rect.width), 0);
y = Math.max(Math.min(y, containerRect.height - rect.height), 0);

draggable.style.left = x + "px";
draggable.style.top = y + "px";
}

2.6 捕获鼠标事件

使用 document.addEventListener('mousemove')document.addEventListener('mouseup')

使用 document.addEventListener 而不是 element.addEventListener 来捕获 mousemovemouseup 事件,以确保即使鼠标移出元素,拖拽操作仍然有效。同时可以解决 拖动太快,导致鼠标脱离的问题,当鼠标拖动的太快,比mousemove事件的触发间隔还要快时,鼠标就会从元素上离开。这样就停止了元素的拖拽过程。此时,如果把mousemovemouseup事件都加在document上时,即可解决

2.7 优化拖拽性能

使用 requestAnimationFrame 来优化拖拽过程中频繁的 DOM 操作,减少重绘和重排的次数。

function onMouseMove(e) {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();

const [x, y] = computeMousemoveXY({ e, downXY, container, draggable });

requestAnimationFrame(() =>
limitDrag({
x,
y,
draggable,
container,
})
);
}

三、实现


3.1 HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.container {
width: 800px;
height: 800px;
position: relative;
border: 1px solid #000;
}
.draggable {
width: 100px;
height: 100px;
background-color: red;
position: absolute;
cursor: grab;
}
</style>
</head>
<body>
<div class="container">
<div class="draggable"></div>
</div>

<script>
let isDragging = false;
let downXY = { x: 0, y: 0 };
const container = document.querySelector(".container");
const draggable = document.querySelector(".draggable");

function computeMousedownXY(e, draggable) {
const rect = draggable.getBoundingClientRect();
const normalizedE = e.type.startsWith("mouse") ? e : e.targetTouches[0];

let x = normalizedE.pageX - rect.left - window.scrollX;
let y = normalizedE.pageY - rect.top - window.scrollY;
return [x, y];
}

function computeMousemoveXY({ e, downXY, container, draggable }) {
const containerRect = container.getBoundingClientRect();
const normalizedE = e.type.startsWith("mouse") ? e : e.targetTouches[0];
const x = normalizedE.pageX - downXY.x - containerRect.left;
const y = normalizedE.pageY - downXY.y - containerRect.top;
return [x, y];
}

function limitDrag({ x, y, draggable, container }) {
const rect = draggable.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();

x = Math.max(Math.min(x, containerRect.width - rect.width), 0);
y = Math.max(Math.min(y, containerRect.height - rect.height), 0);

draggable.style.left = x + "px";
draggable.style.top = y + "px";
}

function onMouseDown(e) {
e.preventDefault();
e.stopPropagation();
const [x, y] = computeMousedownXY(e, draggable);
downXY = { x, y };

isDragging = true;
draggable.style.cursor = "grabbing";
}

function onMouseMove(e) {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();

const [x, y] = computeMousemoveXY({ e, downXY, container, draggable });

requestAnimationFrame(() =>
limitDrag({
x,
y,
draggable,
container,
})
);
}

function onMouseUp(e) {
if (!isDragging) return;
e.preventDefault();
isDragging = false;
draggable.style.cursor = "grab";
}

function run() {
draggable.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);

draggable.addEventListener("touchstart", onMouseDown);
document.addEventListener("touchmove", onMouseMove);
document.addEventListener("touchend", onMouseUp);
}

run();
</script>
</body>
</html>

3.2 React

drag.tsx

import "./drag.css";
import { useRef, useEffect } from "react";

const Drag = () => {
const isDraggingRef = useRef(false);
const downXYRef = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const draggableRef = useRef<HTMLDivElement>(null);

const computeMousedownXY = (e: any) => {
const rect = draggableRef.current?.getBoundingClientRect();
const normalizedE = e.type.startsWith("mouse") ? e : e.touches[0];
if (rect) {
const x = normalizedE.pageX - rect.left - window.scrollX;
const y = normalizedE.pageY - rect.top - window.scrollY;
return { x, y };
}
return { x: 0, y: 0 };
};

const computeMousemoveXY = (e: any) => {
const containerRect = containerRef.current?.getBoundingClientRect();
const normalizedE = e.type.startsWith("mouse") ? e : e.touches[0];
if (containerRect) {
const x = normalizedE.pageX - downXYRef.current.x - containerRect.left;
const y = normalizedE.pageY - downXYRef.current.y - containerRect.top;
return { x, y };
}
return { x: 0, y: 0 };
};

const limitDrag = (x: number, y: number) => {
const rect = draggableRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
if (rect && containerRect) {
x = Math.max(0, Math.min(x, containerRect.width - rect.width));
y = Math.max(0, Math.min(y, containerRect.height - rect.height));

if (draggableRef.current) {
draggableRef.current.style.left = `${x}px`;
draggableRef.current.style.top = `${y}px`;
}
}
};

const onMouseDown = (e: any) => {
e.preventDefault();
e.stopPropagation();
isDraggingRef.current = true;
downXYRef.current = computeMousedownXY(e);
if (draggableRef.current) {
draggableRef.current.style.cursor = "grabbing";
}
};

const onMouseMove = (e: any) => {
if (!isDraggingRef.current) return;
e.preventDefault();
e.stopPropagation();
const { x, y } = computeMousemoveXY(e);
requestAnimationFrame(() => limitDrag(x, y));
};

const onMouseUp = (e: any) => {
if (!isDraggingRef.current) return;
e.preventDefault();
isDraggingRef.current = false;
if (draggableRef.current) {
draggableRef.current.style.cursor = "grab";
}
};

useEffect(() => {
const draggable = draggableRef.current;
if (draggable) {
draggable.addEventListener("mousedown", onMouseDown);
draggable.addEventListener("touchstart", onMouseDown);
}

document.addEventListener("mouseup", onMouseUp);
document.addEventListener("touchend", onMouseUp);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("touchmove", onMouseMove);

return () => {
if (draggable) {
draggable.removeEventListener("mousedown", onMouseDown);
draggable.removeEventListener("touchstart", onMouseDown);
}

document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("touchend", onMouseUp);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("touchmove", onMouseMove);
};
}, []);

return (
<div
className="container"
ref={containerRef}
onMouseDown={onMouseDown}
onTouchStart={onMouseDown}
>
<div className="draggable" ref={draggableRef}></div>
</div>
);
};

export default Drag;

drag.css

.container {
width: 800px;
height: 800px;
position: relative;
border: 1px solid #000;
}

.draggable {
width: 100px;
height: 100px;
background-color: red;
position: absolute;
cursor: grab;
}