拖放 API 与 Pointer Events

深入理解 HTML5 拖放 API、Pointer Events、Touch Events,以及实现现代化拖放交互的完整方案。

拖放 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']
});

参考资料

延展阅读