拖放 API 与 Pointer Events(Drag and Drop API and Pointer Events)
一、拖放 API 概述
1.1 拖放交互模型
HTML5 拖放 API 基于两个主要概念:拖拽源(draggable element)和放置目标(drop target)。整个拖放过程由一系列事件驱动,这些事件在拖拽源和放置目标上分别触发。
<div id="dragSource" draggable="true">
拖动我
</div>
<div id="dropTarget">
放置到这里
</div>
1.2 拖放事件流程
拖拽源事件:
dragstart → 用户开始拖动
drag → 拖动过程中持续触发
dragend → 拖动结束(无论成功与否)
放置目标事件:
dragenter → 拖动元素进入目标
dragover → 拖动元素在目标上移动
dragleave → 拖动元素离开目标
drop → 释放放置
二、拖拽源事件
2.1 dragstart 事件
const source = document.getElementById('dragSource');
source.addEventListener('dragstart', (e) => {
console.log('Drag started');
// 设置拖拽数据
e.dataTransfer.setData('text/plain', 'Hello, drop target!');
e.dataTransfer.setData('text/html', '<b>Bold text</b>');
e.dataTransfer.setData('application/json', JSON.stringify({ id: 1 }));
// 设置拖拽效果
e.dataTransfer.effectAllowed = 'copyMove';
// 设置拖拽图像(可选)
// e.dataTransfer.setDragImage(imgElement, x, y);
});
source.addEventListener('drag', (e) => {
// 拖动过程中持续触发
// 可以用于更新 UI
});
source.addEventListener('dragend', (e) => {
console.log('Drag ended');
console.log('Drop effect:', e.dataTransfer.dropEffect);
});
2.2 DataTransfer 对象
// DataTransfer 的属性
const dt = e.dataTransfer;
dt.items // DataTransferItemList
dt.files // FileList(如果有文件)
dt.types // 设置的数据类型数组
dt.effectAllowed // 允许的拖拽效果
dt.dropEffect // 当前放置效果
// 效果操作
// copy: 复制
// move: 移动
// link: 创建链接
// copyMove, copyLink, linkMove: 组合
// none: 不允许放置
三、放置目标事件
3.1 放置目标的基本设置
const target = document.getElementById('dropTarget');
// 必须阻止默认行为才能接收 drop 事件
target.addEventListener('dragover', (e) => {
e.preventDefault(); // 阻止默认行为,允许放置
e.dataTransfer.dropEffect = 'copy';
});
target.addEventListener('dragenter', (e) => {
e.preventDefault();
target.classList.add('drag-over');
});
target.addEventListener('dragleave', (e) => {
// 检查是否真的离开了(鼠标是否还在目标内)
if (!target.contains(e.relatedTarget)) {
target.classList.remove('drag-over');
}
});
target.addEventListener('drop', (e) => {
e.preventDefault();
// 获取数据
const text = e.dataTransfer.getData('text/plain');
const html = e.dataTransfer.getData('text/html');
const json = e.dataTransfer.getData('application/json');
// 获取文件
const files = e.dataTransfer.files;
target.classList.remove('drag-over');
});
3.2 放置效果视觉反馈
.drop-target {
border: 2px dashed #ccc;
padding: 20px;
transition: all 0.2s;
}
.drop-target.drag-over {
border-color: #4A90D9;
background: #f0f7ff;
}
.drop-target.drag-copy {
border-color: #4A90D9;
}
.drop-target.drag-move {
border-color: #D94A4A;
}
四、文件拖放
4.1 接收拖放的文件
const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
console.log('Dropped files:', files.length);
for (const file of files) {
console.log('File:', file.name, file.size, file.type);
// 读取文件内容
if (file.type.startsWith('text/')) {
const reader = new FileReader();
reader.onload = (event) => {
console.log('Content:', event.target.result);
};
reader.readAsText(file);
} else if (file.type.startsWith('image/')) {
// 预览图片
const url = URL.createObjectURL(file);
console.log('Image URL:', url);
}
}
});
4.2 拖放图片预览
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
for (const file of files) {
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.maxWidth = '200px';
img.style.maxHeight = '200px';
dropZone.appendChild(img);
}
}
});
五、拖放排序列表
5.1 可排序列表实现
class SortableList {
constructor(container) {
this.container = container;
this.items = container.querySelectorAll('.sortable-item');
this.draggedItem = null;
this.draggedIndex = -1;
this.init();
}
init() {
this.container.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
this.items.forEach((item, index) => {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', (e) => {
this.draggedItem = item;
this.draggedIndex = index;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index.toString());
});
item.addEventListener('dragend', (e) => {
item.classList.remove('dragging');
this.draggedItem = null;
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
if (item === this.draggedItem) return;
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
item.parentNode.insertBefore(this.draggedItem, item);
} else {
item.parentNode.insertBefore(this.draggedItem, item.nextSibling);
}
});
item.addEventListener('drop', (e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
const toIndex = Array.from(this.items).indexOf(this.draggedItem);
console.log(`Moved from ${fromIndex} to ${toIndex}`);
});
});
}
}
六、Pointer Events
6.1 Pointer Events 简介
Pointer Events 是一个统一的输入处理 API,整合了鼠标、触摸和手写笔输入。对于需要支持多种输入设备的应用,Pointer Events 比分别处理 mouse 和 touch 事件更简单。
const element = document.getElementById('target');
// 监听 pointer 事件
element.addEventListener('pointerdown', (e) => {
console.log('Pointer down');
console.log('Pointer ID:', e.pointerId);
console.log('Pointer type:', e.pointerType); // 'mouse', 'touch', 'pen'
console.log('Coordinates:', e.clientX, e.clientY);
console.log('Pressure:', e.pressure); // 0-1(支持的压力)
console.log('Tilt:', e.tiltX, e.tiltY); // 手写笔倾斜角度
});
element.addEventListener('pointermove', (e) => {
console.log('Pointer moved');
});
element.addEventListener('pointerup', (e) => {
console.log('Pointer up');
});
element.addEventListener('pointercancel', (e) => {
console.log('Pointer cancelled');
});
6.2 指针捕获
const element = document.getElementById('target');
element.addEventListener('pointerdown', (e) => {
// 捕获指针,后续事件都在 element 上触发
element.setPointerCapture(e.pointerId);
});
element.addEventListener('pointermove', (e) => {
// 即使指针离开元素,依然会收到事件
console.log('Moving at:', e.clientX, e.clientY);
});
element.addEventListener('pointerup', (e) => {
// 释放捕获
element.releasePointerCapture(e.pointerId);
});
七、Touch Events
7.1 Touch Events 与 Pointer Events
虽然 Pointer Events 是更好的选择,但在需要支持旧版浏览器时可能需要 Touch Events:
const element = document.getElementById('target');
// Touch Events
element.addEventListener('touchstart', (e) => {
console.log('Touch started');
const touches = e.touches; // 所有触摸点
const changedTouches = e.changedTouches; // 触发事件的触摸点
for (const touch of touches) {
console.log('Touch ID:', touch.identifier);
console.log('Coordinates:', touch.clientX, touch.clientY);
}
});
element.addEventListener('touchmove', (e) => {
e.preventDefault(); // 阻止默认滚动
});
element.addEventListener('touchend', (e) => {
console.log('Touch ended');
});
element.addEventListener('touchcancel', (e) => {
console.log('Touch cancelled');
});
7.2 手势识别
class GestureRecognizer {
constructor(element) {
this.element = element;
this.touchStartTime = 0;
this.touchStartDistance = 0;
this.touchStartAngle = 0;
this.initialTouches = [];
this.element.addEventListener('touchstart', (e) => this.onTouchStart(e));
this.element.addEventListener('touchmove', (e) => this.onTouchMove(e));
this.element.addEventListener('touchend', (e) => this.onTouchEnd(e));
}
onTouchStart(e) {
this.touchStartTime = Date.now();
this.initialTouches = Array.from(e.touches);
if (e.touches.length === 2) {
// 计算初始距离和角度
const dx = e.touches[1].clientX - e.touches[0].clientX;
const dy = e.touches[1].clientY - e.touches[0].clientY;
this.touchStartDistance = Math.sqrt(dx * dx + dy * dy);
this.touchStartAngle = Math.atan2(dy, dx);
}
}
onTouchMove(e) {
if (e.touches.length === 2) {
// 检测缩放
const dx = e.touches[1].clientX - e.touches[0].clientX;
const dy = e.touches[1].clientY - e.touches[0].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
const scale = distance / this.touchStartDistance;
console.log('Pinch scale:', scale);
// 检测旋转
const angle = Math.atan2(dy, dx);
const rotation = angle - this.touchStartAngle;
console.log('Rotation:', rotation * (180 / Math.PI), 'degrees');
}
}
onTouchEnd(e) {
const duration = Date.now() - this.touchStartTime;
if (duration < 300 && e.touches.length === 0) {
console.log('Tap gesture');
} else if (duration >= 300 && e.touches.length === 0) {
console.log('Long press gesture');
}
}
}
八、综合示例:拖放文件上传
class DragDropUploader {
constructor(dropZone, options = {}) {
this.dropZone = dropZone;
this.options = {
maxSize: 10 * 1024 * 1024, // 10MB
acceptedTypes: ['image/jpeg', 'image/png', 'image/gif'],
...options
};
this.setup();
}
setup() {
// 拖放事件
this.dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
this.dropZone.classList.add('drag-over');
});
this.dropZone.addEventListener('dragleave', (e) => {
if (!this.dropZone.contains(e.relatedTarget)) {
this.dropZone.classList.remove('drag-over');
}
});
this.dropZone.addEventListener('drop', (e) => {
e.preventDefault();
this.dropZone.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
this.processFiles(files);
});
// 点击上传
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = this.options.acceptedTypes.join(',');
input.addEventListener('change', () => {
this.processFiles(Array.from(input.files));
});
this.dropZone.addEventListener('click', () => input.click());
}
processFiles(files) {
for (const file of files) {
if (!this.validateFile(file)) continue;
this.uploadFile(file);
}
}
validateFile(file) {
if (file.size > this.options.maxSize) {
console.error(`File ${file.name} is too large`);
return false;
}
if (!this.options.acceptedTypes.includes(file.type)) {
console.error(`File ${file.name} has unsupported type`);
return false;
}
return true;
}
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (response.ok) {
console.log(`Uploaded: ${file.name}`);
}
} catch (error) {
console.error(`Upload failed: ${file.name}`, error);
}
}
}
// 使用
const uploader = new DragDropUploader(document.getElementById('dropZone'), {
maxSize: 5 * 1024 * 1024,
acceptedTypes: ['image/jpeg', 'image/png']
});