{T}

拖放操作

1. 文档目标与范围

本文档描述浏览器端拖放(Drag and Drop)能力的设计与实践,覆盖:

  • 系统架构与事件流
  • 核心功能模块划分
  • 原生 API 与封装接口说明
  • 配置参数与调优建议
  • 常见问题、错误修正与兼容策略
  • 移动端触摸事件替代方案
  • 可访问性与性能优化
  • 性能基准测试数据
  • 高级拖拽模式实践
  • 主流拖拽库选型对比

适用场景:看板卡片拖拽、列表排序、文件拖放上传、跨区域数据传递。


2. 术语与约定(统一口径)

术语说明备注
拖拽源(Drag Source)被拖动的元素常通过 draggable="true" 启用
放置目标(Drop Target)接收拖放数据的区域必须在 dragoverpreventDefault()
拖放事件(DragEvent拖放生命周期事件对象继承自 MouseEvent,包含 dataTransfer
数据传输对象(DataTransfer拖放时携带的数据容器可读写字符串数据与文件列表
拖放项列表(DataTransferItemListDataTransfer.items 对应集合支持读取类型与数据项
放置效果(dropEffect当前放置行为效果copy / move / link / none
允许效果(effectAllowed拖拽源允许的操作类型通常在 dragstart 设置
拖拽图像(Drag Image)拖拽时显示的视觉反馈通过 setDragImage() 自定义
拖拽手柄(Drag Handle)限制拖拽触发的子元素仅手柄区域可触发拖拽

统一约定

  • "启用放置"默认指:在放置区域的 dragover 调用 event.preventDefault()
  • "文件拖放"默认指:优先读取 event.dataTransfer.files
  • "移动端拖放"默认指:不依赖原生 HTML5 DnD,采用触摸/指针事件方案。

3. 系统架构

<h4>007-datatransfer-api-lifecycle.html</h4>
html
<!DOCTYPE html>
<!--
  来源:基础知识/11-拖放操作.md
  演示:DataTransfer API、setDragImage、完整拖放事件生命周期
-->
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>DataTransfer API 与拖放事件生命周期</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, sans-serif; padding: 30px; background: #f0f2f5; max-width: 960px; margin: 0 auto; }
    h1 { color: #1a1a1a; margin-bottom: 25px; }
    .section { background: white; padding: 25px; margin-bottom: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
    h2 { color: #333; font-size: 17px; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 8px; }

    /* DataTransfer 演示 */
    .data-item {
      display: inline-flex; align-items: center; gap: 8px;
      padding: 12px 20px; margin: 6px; background: linear-gradient(135deg, #667eea, #764ba2);
      color: white; border-radius: 10px; cursor: grab;
      user-select: none; font-size: 14px; font-weight: 500;
      transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
    }
    .data-item:active { cursor: grabbing; }
    .data-item.dragging { opacity: 0.4; transform: scale(0.95); }

    .drop-zone {
      min-height: 120px; padding: 20px; border: 3px dashed #bdc3c7; border-radius: 12px;
      transition: all 0.25s; display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start;
    }
    .drop-zone.active { border-color: #3498db; background: #ebf5fb; box-shadow: inset 0 0 20px rgba(52,152,219,0.08); }
    .drop-zone.success { border-color: #27ae60; background: #eafaf1; }

    /* 事件生命周期可视化 */
    .event-timeline {
      position: relative; padding-left: 30px; font-size: 13px;
    }
    .event-timeline::before {
      content: ''; position: absolute; left: 8px; top: 0; bottom: 0;
      width: 3px; background: linear-gradient(to bottom, #3498db, #9b59b6);
      border-radius: 2px;
    }
    .event-node {
      position: relative; padding: 10px 16px; margin: 8px 0;
      background: #f8f9fa; border-radius: 8px; border-left: 3px solid #ddd;
      animation: slideIn 0.3s ease-out;
    }
    @keyframes slideIn {
      from { opacity: 0; transform: translateX(-15px); }
      to { opacity: 1; transform: translateX(0); }
    }
    .event-node::before {
      content: ''; position: absolute; left: -33px; top: 14px;
      width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;
      box-shadow: 0 0 0 2px #bbb;
    }
    .event-node.dragstart::before { background: #3498db; box-shadow: 0 0 0 2px #3498db; }
    .event-node.dragenter::before { background: #f39c12; box-shadow: 0 0 0 2px #f39c12; }
    .event-node.dragover::before { background: #e67e22; box-shadow: 0 0 0 2px #e67e22; }
    .event-node.dragleave::before { background: #e74c3c; box-shadow: 0 0 0 2px #e74c3c; }
    .event-node.drop::before { background: #27ae60; box-shadow: 0 0 0 2px #27ae60; }
    .event-node.dragend::before { background: #9b59b6; box-shadow: 0 0 0 2px #9b59b6; }

    .event-name { font-weight: bold; font-family: monospace; color: #2c3e50; }
    .event-detail { color: #666; font-size: 12px; margin-top: 3px; }
    .event-time { float: right; color: #aaa; font-size: 11px; }

    table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 15px 0; }
    th, td { padding: 10px 12px; text-align: left; border: 1px solid #dee2e6; }
    th { background: #34495e; color: white; }

    pre { background: #263238; color: #eceff1; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; line-height: 1.6; max-height:300px;overflow-y:auto;}

    .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-family: monospace; }
    .badge-source { background: #e3f2fd; color: #1976d2; }
    .badge-target { background: #fce4ec; color: #c62828; }
  </style>
</head>
<body>
  <h1>🎯 DataTransfer API & 拖放事件生命周期</h1>

  <!-- ========== DataTransfer 数据类型 ========== -->
  <div class="section">
    <h2>1. DataTransfer.setData / getData — 拖拽数据传递</h2>
    <p>将下方数据项拖入目标区域,观察数据传递过程:</p>

    <h3>📤 拖拽源(可拖拽的数据项)</h3>
    <div style="margin:15px 0;">
      <div class="data-item" draggable="true" data-type="text" data-value="Hello World" data-id="item-1">
        📝 文本数据 "Hello World"
      </div>
      <div class="data-item" draggable="true" data-type="json" data-value='{"name":"张三","age":25}' data-id="item-2">
        📦 JSON 数据对象
      </div>
      <div class="data-item" draggable="true" data-type="url" data-value="https://example.com/page" data-id="item-3">
        🔗 URL 链接地址
      </div>
      <div class="data-item" draggable="true" data-type="custom" data-value="my-custom-data-12345" data-id="item-4">
        🔧 自定义数据类型
      </div>
    </div>

    <h3>📥 放置目标(接收数据)</h3>
    <div class="drop-zone" id="dt-drop-zone">
      <span style="color:#999;width:100%;text-align:center;padding:20px;">将上方数据项拖到这里</span>
    </div>

    <div id="dt-result" style="margin-top:12px;background:#f8f9fa;padding:12px;border-radius:6px;font-family:monospace;font-size:12px;display:none;"></div>

    <pre id="dt-log" style="margin-top:12px;">// DataTransfer 操作日志...</pre>
  </div>

  <!-- ========== setDragImage ========== -->
  <div class="section">
    <h2>2. setDragImage 自定义拖拽图像</h2>
    <p>默认拖拽图像是元素的半透明副本,可以通过 setDragImage 自定义:</p>

    <div style="display:flex;gap:15px;flex-wrap:wrap;margin:15px 0;">
      <div class="data-item" draggable="true" id="drag-default"
           ondragstart="setDragDefault(event)">默认拖拽图像</div>
      <div class="data-item" draggable="true" style="background:linear-gradient(135deg,#27ae60,#2ecc71);" id="drag-custom-img"
           ondragstart="setCustomDragImage(event)">自定义图片拖拽</div>
      <div class="data-item" draggable="true" style="background:linear-gradient(135deg,#e74c3c,#c0392b);" id="drag-offset"
           ondragstart="setDragWithOffset(event)">偏移量拖拽</div>
      <div class="data-item" draggable="true" style="background:linear-gradient(135deg,#f39c12,#f1c40f);" id="drag-hidden"
           ondragstart="setHiddenDrag(event)">隐藏原始元素</div>
    </div>

    <pre>// setDragImage(imgElement, offsetX, offsetY)
// imgElement: 可以是 DOM 元素或 Image 对象
// offsetX/Y:   鼠标相对于图像左上角的偏移

// 常见用法:
e.dataTransfer.setDragImage(customImg, 0, 0);     // 左上角对齐
e.dataTransfer.setDragImage(customImg, 20, 20);    // 居中偏移
e.dataTransfer.setDragImage(new Image(), 0, 0);     // 完全隐藏拖拽图像

// 注意: 必须在 dragstart 事件中同步调用!</pre>
  </div>

  <!-- ========== 事件生命周期 ========== -->
  <div class="section">
    <h2>3. 拖放事件完整生命周期</h2>
    <p>拖动下方的圆形元素经过各个区域,实时查看事件触发顺序:</p>

    <div style="display:flex;gap:30px;align-items:flex-start;">
      <div style="flex:1;">
        <div id="lifecycle-draggable" draggable="true"
             ondragstart="logEvent('dragstart', event)"
             ondragend="logEvent('dragend', event)"
             style="width:70px;height:70px;border-radius:50%;background:linear-gradient(135deg,#667eea,#764ba2);
                    display:flex;align-items:center;justify-content:center;color:white;
                    font-size:24px;cursor:grab;user-select:none;box-shadow:0 4px 15px rgba(102,126,234,0.4);">
          ⚡
        </div>
        <p style="text-align:center;margin-top:8px;font-size:12px;color:#888;">拖动这个圆球 →</p>
      </div>

      <div style="flex:2;">
        <div id="lifecycle-target"
             ondragenter="logEvent('dragenter', event)"
             ondragover="handleDragOver(event)"
             ondragleave="logEvent('dragleave', event)"
             ondrop="logEvent('drop', event)"
             class="drop-zone" style="min-height:200px;position:relative;">
          <span style="color:#999;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);">拖到这个区域<br>观察事件序列</span>
        </div>
      </div>
    </div>

    <h3 style="margin:18px 0 10px;">📋 事件日志(时间线)</h3>
    <button onclick="clearEventLog()" style="margin-bottom:10px;background:#95a5a6;color:white;padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px;">清空日志</button>
    <div class="event-timeline" id="event-timeline">
      <div style="color:#999;text-align:center;padding:20px;">拖拽后事件将按时间顺序显示在这里...</div>
    </div>
  </div>

  <!-- ========== DataTransfer 完整 API ========== -->
  <div class="section">
    <h2>4. DataTransfer 完整 API 参考</h2>

    <table>
      <thead><tr><th>方法/属性</th><th>说明</th></tr></thead>
      <tbody>
        <tr><td><code>setData(format, data)</code></td><td>设置拖拽数据,format 如 'text/plain', 'text/uri-list'</td></tr>
        <tr><td><code>getData(format)</code></td><td>获取指定格式的数据(仅在 drop 中有效)</td></tr>
        <tr><td><code>clearData(format)</code></td><td>清除指定格式或全部数据</td></tr>
        <tr><td><code>setDragImage(el, x, y)</code></td><td>自定义拖拽时的跟随图像</td></tr>
        <tr><td><code>types</code></td><td>只读,当前存储的数据格式列表(DOMStringList)</td></tr>
        <tr><td><code>files</code></td><td>只读,被拖拽的 File 对象列表(FileList)</td></tr>
        <tr><td><code>effectAllowed</code></td><td>允许的效果: none/copy/move/link/all/copyMove/copyLink/linkMove</td></tr>
        <tr><td><code>dropEffect</code></td><td>实际效果: none/copy/move/link(需与 effectAllowed 兼容)</td></tr>
        <tr><td><code>items</code></td><td>只读,DataTransferItemList(支持更多数据类型)</td></tr>
      </tbody>
    </table>

    <h3 style="margin:18px 0 10px;">事件触发顺序总结</h3>
    <pre style="font-size:11px;">┌─ 源元素 ─────────────────────────────┐
│  dragstart  ← 开始拖拽(必须 setData)│
│  drag       ← 拖拽过程中持续触发         │
│  dragend    ← 拖拽结束(无论是否 drop)  │
└──────────────────────────────────────────┘

┌─ 目标元素 ─────────────────────────────┐
│  dragenter  ← 拖拽物进入元素范围          │
│  dragover   ← 在元素范围内移动(必须 preventDefault 才能接收 drop)│
│  dragleave  ← 离开元素范围               │
│  drop       ← 释放鼠标(在此 getData)    │
└──────────────────────────────────────────┘

⚠️ 关键点:
1. dragstart 中调用 e.dataTransfer.setData() 设置数据
2. dragover 中必须调用 e.preventDefault() + e.dataTransfer.dropEffect = 'move'
3. drop 中调用 e.preventDefault() + e.dataTransfer.getData() 获取数据
4. dragend 标志整个拖放流程结束</pre>
  </div>

  <!-- ========== touch-action 与触摸拖拽 ========== -->
  <div class="section">
    <h2>5. touch-action CSS 属性与触摸拖拽</h2>
    <p><code>touch-action</code> 控制触摸操作的浏览器默认行为:</p>

    <table>
      <thead><tr><th>值</th><th>效果</th><th>适用场景</th></tr></thead>
      <tbody>
        <tr><td><code>auto</code></td><td>浏览器决定(默认)</td><td>一般网页</td></tr>
        <tr><td><code>none</code></td><td>禁用所有触摸手势</td><td>自定义触摸交互</td></tr>
        <tr><td><code>pan-x</code></td><td>仅允许水平滚动</td><td>水平轮播图</td></tr>
        <tr><td><code>pan-y</code></td><td>仅允许垂直滚动</td><td>垂直列表</td></tr>
        <tr><td><code>manipulation</code></td><td>允许双指缩放但禁用双击缩放</td><td>按钮、链接</td></tr>
        <tr><td><code>pinch-zoom</code></td><td>仅允许双指缩放</td><td>地图、图片查看器</td></tr>
      </tbody>
    </table>

    <pre style="margin-top:12px;font-size:11px;">/* 移动端拖拽元素推荐设置 */
.draggable-element {
  touch-action: none;  /* 禁止浏览器默认触摸行为 */
  user-select: none;   /* 禁止文本选择 */
  -webkit-user-drag: element; /* Safari 支持 */
}

/* 可滚动容器内的拖拽区域 */
.scrollable-container .drag-item {
  touch-action: pan-y; /* 保留垂直滚动,禁止水平手势干扰拖拽 */
}</pre>
  </div>

  <script>
    // ===== DataTransfer 演示 =====
    const dtLog = document.getElementById('dt-log');
    const dtResult = document.getElementById('dt-result');
    const dtZone = document.getElementById('dt-drop-zone');

    document.querySelectorAll('.data-item[draggable]').forEach(item => {
      item.addEventListener('dragstart', (e) => {
        const type = item.dataset.type;
        const value = item.dataset.value;
        const id = item.dataset.id;

        // 设置多种格式的数据
        e.dataTransfer.setData('text/plain', value);
        e.dataTransfer.setData('application/json', value);
        e.dataTransfer.setData(`custom/${type}`, value);

        e.dataTransfer.effectAllowed = 'copy';
        e.dataTransfer.setData('text/id', id);

        item.classList.add('dragging');
        logDT(`[dragstart] 类型: ${type}, ID: ${id}`);
        logDT(`  setData('text/plain'): "${value}"`);
        logDT(`  setData('custom/${type}'): "${value}"`);
        logDT(`  effectAllowed: "copy"`);
        logDT(`  types: [${Array.from(e.dataTransfer.types).join(', ')}]`);
      });

      item.addEventListener('dragend', () => {
        item.classList.remove('dragging');
        dtZone.classList.remove('active', 'success');
        logDT('[dragend] 拖拽结束');
      });
    });

    dtZone.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
      dtZone.classList.add('active');
    });

    dtZone.addEventListener('dragleave', () => {
      dtZone.classList.remove('active');
    });

    dtZone.addEventListener('drop', (e) => {
      e.preventDefault();
      dtZone.classList.remove('active');
      dtZone.classList.add('success');

      const plainText = e.dataTransfer.getData('text/plain');
      const jsonData = e.dataTransfer.getData('application/json');
      const customType = Array.from(e.dataTransfer.types).find(t => t.startsWith('custom/'));

      dtResult.style.display = 'block';
      dtResult.innerHTML =
        `<strong>✅ 接收到数据:</strong><br>` +
        `text/plain: <span style="color:#3498db;">${plainText}</span><br>` +
        `application/json: <span style="color:#27ae60;">${jsonData}</span><br>` +
        `自定义类型 (${customType}): ${e.dataTransfer.getData(customType)}<br>` +
        `types 数组: [${Array.from(e.dataTransfer.types).join(', ')}]`;

      setTimeout(() => dtZone.classList.remove('success'), 1500);
      logDT(`[drop] 成功接收数据! text="${plainText}"`);
    });

    function logDT(msg) {
      dtLog.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + dtLog.textContent;
    }

    // ===== setDragImage 演示 =====
    function setDragDefault(e) {
      // 不调用 setDragImage → 使用默认半透明克隆
      console.log('使用默认拖拽图像');
    }

    function setCustomDragImage(e) {
      const img = new Image();
      img.src = 'data:image/svg+xml,' + encodeURIComponent(
        '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">' +
        '<rect rx="8" fill="#27ae60" width="120" height="40"/>' +
        '<text x="60" y="25" fill="white" text-anchor="middle" font-size="14">✨ 拖拽中!</text>' +
        '</svg>'
      );
      e.dataTransfer.setDragImage(img, 60, 20);
    }

    function setDragWithOffset(e) {
      const el = document.getElementById('drag-offset');
      e.dataTransfer.setDragImage(el, el.offsetWidth / 2, el.offsetHeight / 2);
    }

    function setHiddenDrag(e) {
      const emptyImg = new Image(1, 1);
      emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
      e.dataTransfer.setDragImage(emptyImg, 0, 0);
    }

    // ===== 事件生命周期 =====
    let eventCount = 0;
    const timeline = document.getElementById('event-timeline');

    function handleDragOver(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      document.getElementById('lifecycle-target').classList.add('active');
    }

    function logEvent(eventName, e) {
      if (eventName === 'dragstart') {
        timeline.innerHTML = '';
        eventCount = 0;
      }

      eventCount++;
      const node = document.createElement('div');
      node.className = `event-node ${eventName}`;

      let detail = '';
      if (eventName === 'dragstart') detail = `effectAllowed 设为 "move",开始传输数据`;
      else if (eventName === 'dragenter') detail = `拖拽物进入目标区域`;
      else if (eventName === 'dragover') detail = `持续触发 (~250ms/次),需 preventDefault()`;
      else if (eventName === 'dragleave') detail = `离开目标区域边界`;
      else if (eventName === 'drop') {
        detail = `释放!在此处 getData() 获取数据`;
        document.getElementById('lifecycle-target').classList.remove('active');
        document.getElementById('lifecycle-target').classList.add('success');
        setTimeout(() => document.getElementById('lifecycle-target').classList.remove('success'), 1000);
      } else if (eventName === 'dragend') {
        detail = `整个拖放流程结束。dropSuccess=${!!e.dataTransfer.dropEffect}`;
        document.getElementById('lifecycle-target').classList.remove('active');
      }

      node.innerHTML =
        `<span class="event-time">#${eventCount}</span>` +
        `<span class="event-name">${eventName}</span>` +
        `<div class="event-detail">${detail}</div>`;

      timeline.appendChild(node);
      timeline.scrollTop = timeline.scrollHeight;
    }

    function clearEventLog() {
      timeline.innerHTML = '<div style="color:#999;text-align:center;padding:20px;">日志已清空,重新拖拽以记录事件</div>';
    }
  </script>
</body>
</html>

3.1 架构分层

图表渲染中…

3.2 事件时序(文件拖放)

图表渲染中…

3.3 事件生命周期详解

图表渲染中…

事件触发顺序(完整流程)

code
拖拽源                    放置目标
  |                          |
  |--- dragstart -----------|  开始拖拽
  |--- drag ---------------|  拖拽中(高频)
  |                    dragenter  进入目标
  |                    dragover   悬停目标(高频)
  |                    dragleave  离开目标(可选)
  |--- drag ---------------|  继续拖拽
  |                       drop     放置
  |--- dragend ----------|  结束拖拽(无论成功失败)

3.4 事件传播与冒泡

拖放事件遵循标准 DOM 事件传播机制:捕获阶段 → 目标阶段 → 冒泡阶段

关键注意事项

事件冒泡特性注意事项
dragstart冒泡在捕获阶段拦截可阻止拖拽启动
drag冒泡高频触发,避免在父元素监听
dragenter冒泡子元素会触发父元素的 dragleave
dragover冒泡可在父容器统一处理
drop冒泡需阻止冒泡避免触发父级处理
dragend冒泡无论放置成功与否都会触发

事件委托示例

javascript
// 在父容器上统一处理拖放(适用于动态列表)
container.addEventListener("dragstart", (e) => {
  const item = e.target.closest("[draggable]")
  if (!item) return
  // 处理拖拽开始
}, true) // 使用捕获阶段,优先于子元素处理

container.addEventListener("drop", (e) => {
  e.stopPropagation() // 阻止冒泡,避免触发外层处理器
  // 处理放置
})

3.5 DataTransfer 数据类型选择决策树

在拖放操作中,根据不同的数据传输需求,需要选择合适的 setData/getData MIME 类型。以下决策树帮助开发者快速确定最佳数据类型策略:

图表渲染中…

各数据类型的典型应用场景

MIME 类型典型场景安全等级推荐度
text/plain传递 ID、简单文本⭐⭐⭐ 高★★★★★ 最常用
application/json传递复杂业务对象⭐⭐⭐ 高★★★★★ 推荐
text/uri-list拖拽链接、URL⭐⭐⭐ 高★★★★☆ 常用
text/html富文本片段拖放⭐ 低(XSS 风险)★★☆☆☆ 谨慎使用
Files(隐式)文件拖放上传⭐⭐ 中(需服务端校验)★★★★★ 核心场景

3.6 模块边界建议

  • 交互模块:只处理 UI 与用户反馈,不直接写业务规则。
  • 控制模块:统一管理拖放事件、状态与数据提取。
  • 校验模块:封装可放置规则、文件规则、权限规则。
  • 业务模块:负责排序持久化、上传与失败重试。

4. 核心功能模块

4.1 拖拽源模块

  • 设置 draggable="true" 使元素可拖动。
  • dragstart 写入业务数据(setData)并声明 effectAllowed
  • dragend 清理拖拽态样式与临时状态。

状态管理建议

javascript
const dragState = {
  source: null,      // 当前拖拽元素
  data: null,        // 携带数据
  startTime: 0,      // 拖拽开始时间
  canceled: false    // 是否取消
}

4.2 放置目标模块

  • dragenter / dragleave:处理区域高亮与离开反馈。
  • dragover:调用 preventDefault() 以允许放置。
  • drop:读取 dataTransfer,执行业务逻辑并清理状态。

嵌套放置区域处理

javascript
// 避免子元素触发 dragleave 导致闪烁
let dragEnterCount = 0

dropzone.addEventListener("dragenter", (e) => {
  dragEnterCount++
  dropzone.classList.add("active")
})

dropzone.addEventListener("dragleave", (e) => {
  dragEnterCount--
  if (dragEnterCount === 0) {
    dropzone.classList.remove("active")
  }
})

dropzone.addEventListener("drop", (e) => {
  dragEnterCount = 0
  dropzone.classList.remove("active")
})

4.3 数据传输模块

  • 文本/结构化数据:setData("text/plain", value)setData("application/json", json)
  • 文件数据:event.dataTransfer.filesevent.dataTransfer.items
  • 受浏览器安全限制,跨源与跨应用拖放行为存在差异。

DataTransfer 读写注意事项

javascript
// 正确:在 dragstart 中写入数据
element.addEventListener("dragstart", (e) => {
  e.dataTransfer.setData("application/json", JSON.stringify(data))
  e.dataTransfer.effectAllowed = "move"
})

// 正确:在 drop 中读取数据
dropzone.addEventListener("drop", (e) => {
  const json = e.dataTransfer.getData("application/json")
  if (json) {
    const data = JSON.parse(json)
    // 处理数据
  }
})

// 错误:在 dragover 中读取数据(安全限制,数据不可用)
dropzone.addEventListener("dragover", (e) => {
  e.preventDefault()
  // e.dataTransfer.getData() 返回空字符串
})

4.4 文件拖放模块

  • 支持"拖放 + 点击选择"双入口,提升可用性。
  • 校验数量、类型、大小后再触发上传。
  • 显示上传进度、失败原因与重试按钮。

文件拖放增强功能

  • 拖放文件夹(webkitGetAsEntry API)
  • 分片上传大文件
  • 拖放预览(图片、视频)

4.5 列表排序模块

  • 计算当前拖拽项目标索引并更新 DOM 顺序。
  • 更新本地状态后调用后端持久化排序结果。
  • 避免频繁重排,必要时使用节流或 requestAnimationFrame

排序算法优化

javascript
// 使用二分查找优化大列表排序位置计算
function findInsertIndex(list, dragY) {
  const items = Array.from(list.children)
  let low = 0, high = items.length - 1

  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    const rect = items[mid].getBoundingClientRect()
    const midY = rect.top + rect.height / 2

    if (dragY < midY) {
      high = mid - 1
    } else {
      low = mid + 1
    }
  }
  return low
}

拖拽排序算法时序图

以下时序图展示了从用户拖拽到 DOM 更新完成的完整排序流程,重点展示位置计算、DOM 操作和动画过渡的协作关系:

图表渲染中…

FLIP 动画原理说明

上述时序图中提到的 FLIP 是一种高性能动画技术,特别适合拖拽排序场景:

步骤操作说明
First记录元素的初始位置在 DOM 变更前调用 getBoundingClientRect()
Last执行 DOM 变更insertBefore 后元素已在新位置
Invert用 transform 将元素移回原位计算 newRect - oldRect 的差值
Play用 CSS transition 动画回新位置移除 transform,触发平滑过渡

4.6 可访问性模块

  • 为可拖拽元素提供键盘替代操作(上移/下移按钮)。
  • 使用 aria-grabbedaria-dropeffect 与实时状态提示文本。
  • 保证无鼠标场景可完成核心业务流程。

5. API 接口说明

5.1 拖放事件总览

事件触发主体触发时机关键说明
dragstart拖拽源开始拖拽可设置 dataTransfereffectAllowed
drag拖拽源拖拽过程中高频事件,避免重计算
dragend拖拽源拖拽结束无论成功失败都会触发
dragenter放置目标拖拽进入目标用于高亮放置区
dragover放置目标悬停目标上方必须 preventDefault()
dragleave放置目标离开目标清理高亮状态
drop放置目标释放鼠标读取数据并执行业务逻辑

5.2 DragEvent 接口

typescript
interface DragEvent extends MouseEvent {
  readonly dataTransfer: DataTransfer | null
}

继承自 MouseEvent 的常用属性

属性类型说明
clientX / clientYnumber视口坐标
pageX / pageYnumber文档坐标(含滚动)
screenX / screenYnumber屏幕坐标
targetEventTarget事件目标元素
currentTargetEventTarget事件绑定元素
relatedTargetEventTarget相关元素(dragenter/dragleave

5.3 DataTransfer 核心属性与方法

名称类型说明
effectAllowedstring拖拽源允许的操作类型
dropEffectstring当前放置效果
typesDOMStringList当前可读取的数据类型列表
filesFileList拖放的文件集合
itemsDataTransferItemList数据项集合(文本/文件)
setData(type, data)function写入字符串数据
getData(type)function读取字符串数据
clearData(type?)function清理数据
setDragImage(el, x, y)function设置自定义拖拽图像

effectAllowed 与 dropEffect 配合

effectAllowed含义对应 dropEffect
none不允许任何操作none
copy允许复制copy
move允许移动move
link允许链接link
copyMove允许复制或移动copy / move
copyLink允许复制或链接copy / link
linkMove允许链接或移动link / move
all允许所有操作任意
javascript
// 源元素声明只允许移动
source.addEventListener("dragstart", (e) => {
  e.dataTransfer.effectAllowed = "move"
})

// 目标元素根据位置决定效果
target.addEventListener("dragover", (e) => {
  e.preventDefault()
  e.dataTransfer.dropEffect = isCopyArea(e) ? "copy" : "move"
})

5.4 DataTransferItemListDataTransferItem

typescript
interface DataTransferItemList {
  length: number
  add(data: string, type: string): DataTransferItem
  add(file: File): DataTransferItem
  remove(index: number): void
  clear(): void
  [index: number]: DataTransferItem
}

interface DataTransferItem {
  kind: "string" | "file"
  type: string
  getAsString(callback: (data: string) => void): void
  getAsFile(): File | null
  webkitGetAsEntry(): FileSystemEntry | null  // 用于读取文件夹
}

使用示例

javascript
// 区分文本和文件
dropzone.addEventListener("drop", (e) => {
  const items = e.dataTransfer.items

  for (const item of items) {
    if (item.kind === "file") {
      const file = item.getAsFile()
      console.log("文件:", file.name)
    } else if (item.kind === "string") {
      item.getAsString((data) => {
        console.log("文本:", data)
      })
    }
  }
})

5.5 推荐封装接口(示例)

ts
export interface DragPayload {
  type: "card" | "file" | "text"
  id?: string
  data?: Record<string, unknown>
}

export interface DropzoneOptions {
  accept?: string[]
  maxCount?: number
  maxSize?: number
  disabled?: boolean
  highlightClass?: string
  preventBodyDrop?: boolean
}

export interface DropResult<T = unknown> {
  ok: boolean
  reason?: string
  payload?: T
  files?: File[]
}

export function bindDropzone(el: HTMLElement, options: DropzoneOptions): () => void
export function setDragPayload(event: DragEvent, payload: DragPayload): void
export function getDragPayload(event: DragEvent): DragPayload | null
export function validateDroppedFiles(files: File[], options: DropzoneOptions): string[]

5.6 服务端接口约定(拖放上传场景)

接口方法用途关键请求参数关键响应字段
/api/dnd/uploadPOST拖放文件直传multipart/form-data,字段 filefileIdurl
/api/dnd/upload/chunkPOST上传文件分片fileIdchunkIndexchunkreceived
/api/dnd/upload/mergePOST合并分片fileIdfileNameurlsize
/api/dnd/sortPOST持久化排序结果listIdorderedIdssuccess

状态码建议

  • 200/201:处理成功。
  • 400:参数或数据格式错误。
  • 401/403:未授权或无权限。
  • 413:文件过大。
  • 415:媒体类型不支持。
  • 500:服务端异常。

6. 配置参数详解

6.1 Dropzone 配置

参数类型默认值说明
acceptstring[][]允许拖入的 MIME/扩展名白名单
maxCountnumber1最大文件数量
maxSizenumber10MB单文件大小上限
disabledbooleanfalse是否禁用拖放
highlightClassstring"drag-over"悬停高亮类名
preventBodyDropbooleantrue是否拦截页面级默认放置行为

6.2 排序配置

参数类型默认值说明
axis"x" / "y" / "both""y"允许拖拽方向
handleSelectorstring拖拽手柄选择器
animationMsnumber150排序动画时长
ghostClassstring"dragging"拖拽态样式类
onMovefunction拖拽过程中回调
onDropfunction放置完成回调

6.3 调优建议

  • 高频 dragover 场景:将位置计算放到 requestAnimationFrame
  • 大量列表项排序:优先更新数据模型,批量提交 DOM 变化。
  • 页面级文件拖放:统一拦截 documentdragover/drop,避免浏览器直接打开文件。

7. 使用示例

<h4>005-image-drop-preview.html</h4>
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>【5】拖放图片预览</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
    .demo-container { max-width: 700px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }

    .preview-zone {
      width: 100%;
      height: 350px;
      border: 3px dashed #ccc;
      border-radius: 12px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #fafafa;
      overflow: hidden;
      position: relative;
      transition: all 0.3s;
    }

    .preview-zone.drag-over {
      border-color: #007bff;
      background: #e7f3ff;
    }

    .preview-zone.has-image {
      border-style: solid;
      border-color: #ddd;
    }

    .placeholder {
      text-align: center;
      color: #999;
    }
    .placeholder .icon { font-size: 64px; margin-bottom: 16px; }
    .placeholder p { font-size: 15px; }
    .placeholder .hint { font-size: 13px; color: #aaa; margin-top: 8px; }

    .preview-image {
      max-width: 100%;
      max-height: 100%;
      object-fit: contain;
      animation: fadeIn 0.3s ease;
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: scale(0.95); }
      to { opacity: 1; transform: scale(1); }
    }

    .image-info {
      margin-top: 16px;
      padding: 16px;
      background: #f8f9fa;
      border-radius: 8px;
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 12px;
    }

    .info-item {
      text-align: center;
    }
    .info-label { font-size: 12px; color: #666; margin-bottom: 4px; }
    .info-value { font-weight: 600; color: #333; font-size: 14px; }

    .actions {
      margin-top: 16px;
      text-align: center;
    }

    .btn {
      padding: 10px 24px;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 500;
      transition: all 0.3s;
    }
    .btn-primary { background: #007bff; color: white; }
    .btn-primary:hover { background: #0056b3; }
    .btn-secondary { background: #6c757d; color: white; }
    .btn-secondary:hover { background: #5a6268; }
  </style>
</head>
<body>
  <div class="demo-container">
    <div class="demo-title">示例:拖放图片预览 - Object URL 方式</div>

    <div class="preview-zone" id="previewZone">
      <div class="placeholder" id="placeholder">
        <div class="icon">🖼️</div>
        <p><strong>拖放图片到此处预览</strong></p>
        <p class="hint">支持 JPG、PNG、GIF、WebP 格式</p>
      </div>
    </div>

    <div class="image-info" id="imageInfo" style="display: none;">
      <div class="info-item">
        <div class="info-label">文件名</div>
        <div class="info-value" id="fileName">-</div>
      </div>
      <div class="info-item">
        <div class="info-label">文件大小</div>
        <div class="info-value" id="fileSize">-</div>
      </div>
      <div class="info-item">
        <div class="info-label">MIME 类型</div>
        <div class="info-value" id="fileType">-</div>
      </div>
    </div>

    <div class="actions" id="actions" style="display: none;">
      <button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">选择其他图片</button>
      <button class="btn btn-secondary" onclick="clearPreview()">清除预览</button>
      <input type="file" id="fileInput" accept="image/*" style="display:none;" />
    </div>
  </div>

  <script>
    const previewZone = document.getElementById("previewZone")
    const placeholder = document.getElementById("placeholder")
    const imageInfo = document.getElementById("imageInfo")
    const actions = document.getElementById("actions")
    const fileInput = document.getElementById("fileInput")

    let currentUrl = null

    // 拖拽事件
    previewZone.addEventListener("dragover", (event) => {
      event.preventDefault()
      previewZone.classList.add("drag-over")
    })

    previewZone.addEventListener("dragleave", () => {
      previewZone.classList.remove("drag-over")
    })

    previewZone.addEventListener("drop", (event) => {
      event.preventDefault()
      previewZone.classList.remove("drag-over")

      const file = event.dataTransfer.files[0]
      if (file) showPreview(file)
    })

    // 文件选择
    fileInput.addEventListener("change", (event) => {
      const file = event.target.files?.[0]
      if (file) showPreview(file)
    })

    function showPreview(file) {
      if (!file.type.startsWith("image/")) {
        alert("请拖放图片文件!")
        return
      }

      // 清理之前的 URL
      if (currentUrl) URL.revokeObjectURL(currentUrl)

      currentUrl = URL.createObjectURL(file)

      placeholder.style.display = "none"

      const img = document.createElement("img")
      img.className = "preview-image"
      img.src = currentUrl
      img.alt = file.name
      img.onload = () => URL.revokeObjectURL(currentUrl)

      previewZone.appendChild(img)
      previewZone.classList.add("has-image")

      // 显示信息
      imageInfo.style.display = "grid"
      actions.style.display = "block"

      document.getElementById("fileName").textContent = file.name
      document.getElementById("fileSize").textContent = formatBytes(file.size)
      document.getElementById("fileType").textContent = file.type || "未知"
    }

    function clearPreview() {
      if (currentUrl) URL.revokeObjectURL(currentUrl)
      currentUrl = null

      const img = previewZone.querySelector(".preview-image")
      if (img) img.remove()

      placeholder.style.display = "block"
      previewZone.classList.remove("has-image")
      imageInfo.style.display = "none"
      actions.style.display = "none"
      fileInput.value = ""
    }

    function formatBytes(bytes) {
      if (bytes === 0) return "0 B"
      const k = 1024
      const sizes = ["B", "KB", "MB", "GB"]
      const i = Math.floor(Math.log(bytes) / Math.log(k))
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
    }
  </script>
</body>
</html>```

### 7.1 示例一:基础数据拖放

```html
<div id="dragCard" draggable="true">拖拽卡片 A</div>
<div id="dropZone">放到这里</div>

<script>
  const dragCard = document.getElementById("dragCard")
  const dropZone = document.getElementById("dropZone")

  dragCard.addEventListener("dragstart", (event) => {
    event.dataTransfer.effectAllowed = "move"
    event.dataTransfer.setData("application/json", JSON.stringify({ id: "card-a", type: "card" }))
  })

  dropZone.addEventListener("dragover", (event) => {
    event.preventDefault()
    event.dataTransfer.dropEffect = "move"
  })

  dropZone.addEventListener("drop", (event) => {
    event.preventDefault()
    const raw = event.dataTransfer.getData("application/json")
    if (!raw) return
    const payload = JSON.parse(raw)
    dropZone.textContent = `已放置:${payload.id}`
  })
</script>

7.2 示例二:文件拖放 + 校验 + 回退选择

html
<div id="fileDrop" class="drop-zone">拖放文件到此,或点击选择</div>
<input id="fileInput" type="file" multiple hidden />
<ul id="result"></ul>

<script>
  const fileDrop = document.getElementById("fileDrop")
  const fileInput = document.getElementById("fileInput")
  const result = document.getElementById("result")
  const ALLOWED = ["image/png", "image/jpeg", "application/pdf"]
  const MAX_SIZE = 5 * 1024 * 1024

  fileDrop.addEventListener("click", () => fileInput.click())
  fileDrop.addEventListener("dragover", (event) => event.preventDefault())
  fileDrop.addEventListener("drop", (event) => {
    event.preventDefault()
    handleFiles(Array.from(event.dataTransfer.files))
  })
  fileInput.addEventListener("change", (event) => handleFiles(Array.from(event.target.files || [])))

  function handleFiles(files) {
    result.innerHTML = ""
    for (const file of files) {
      const errors = []
      if (!ALLOWED.includes(file.type)) errors.push("类型不支持")
      if (file.size > MAX_SIZE) errors.push("超出 5MB")
      const li = document.createElement("li")
      li.textContent = `${file.name} - ${errors.length ? errors.join("、") : "校验通过"}`
      result.appendChild(li)
    }
  }
</script>

7.3 示例三:可排序列表(最小实现)

html
<ul id="sortable">
  <li draggable="true" data-id="1">任务 1</li>
  <li draggable="true" data-id="2">任务 2</li>
  <li draggable="true" data-id="3">任务 3</li>
</ul>

<script>
  const list = document.getElementById("sortable")
  let dragging = null

  list.addEventListener("dragstart", (event) => {
    const target = event.target.closest("li")
    if (!target) return
    dragging = target
    event.dataTransfer.effectAllowed = "move"
  })

  list.addEventListener("dragover", (event) => {
    event.preventDefault()
    const target = event.target.closest("li")
    if (!target || target === dragging) return
    const rect = target.getBoundingClientRect()
    const isAfter = event.clientY > rect.top + rect.height / 2
    list.insertBefore(dragging, isAfter ? target.nextSibling : target)
  })

  list.addEventListener("drop", (event) => {
    event.preventDefault()
    const orderedIds = Array.from(list.querySelectorAll("li")).map((item) => item.dataset.id)
    console.log("最新顺序:", orderedIds)
    dragging = null
  })
</script>

7.4 示例四:自定义拖拽图像

html
<style>
  .drag-preview {
    position: absolute;
    top: -9999px;
    background: #4a90d9;
    color: white;
    padding: 8px 16px;
    border-radius: 4px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
    font-weight: bold;
    pointer-events: none;
  }
</style>

<div id="draggable" draggable="true">拖拽我</div>
<div id="preview" class="drag-preview">正在拖拽中...</div>

<script>
  const draggable = document.getElementById("draggable")
  const preview = document.getElementById("preview")

  draggable.addEventListener("dragstart", (event) => {
    event.dataTransfer.effectAllowed = "move"
    // 设置自定义拖拽图像
    event.dataTransfer.setDragImage(preview, 50, 20)
  })
</script>

7.5 示例五:拖拽手柄

html
<style>
  .item { display: flex; align-items: center; padding: 8px; background: #f5f5f5; margin: 4px 0; }
  .handle { cursor: grab; padding: 0 12px; color: #999; user-select: none; }
  .item.dragging { opacity: 0.5; }
</style>

<div id="list">
  <div class="item" draggable="true" data-id="1">
    <span class="handle">☰</span>
    <span class="content">项目 1</span>
  </div>
  <div class="item" draggable="true" data-id="2">
    <span class="handle">☰</span>
    <span class="content">项目 2</span>
  </div>
</div>

<script>
  const list = document.getElementById("list")
  let dragging = null
  let validDrag = false

  list.addEventListener("dragstart", (event) => {
    const handle = event.target.closest(".handle")
    // 只允许通过手柄拖拽
    if (!handle) {
      event.preventDefault()
      return
    }
    validDrag = true
    dragging = event.target.closest(".item")
    dragging.classList.add("dragging")
    event.dataTransfer.effectAllowed = "move"
  })

  list.addEventListener("dragend", () => {
    if (dragging) dragging.classList.remove("dragging")
    dragging = null
    validDrag = false
  })

  list.addEventListener("dragover", (event) => {
    event.preventDefault()
    if (!validDrag || !dragging) return
    const target = event.target.closest(".item")
    if (!target || target === dragging) return
    const rect = target.getBoundingClientRect()
    const isAfter = event.clientY > rect.top + rect.height / 2
    list.insertBefore(dragging, isAfter ? target.nextSibling : target)
  })
</script>

7.6 示例六:跨容器拖放

html
<style>
  .container { display: inline-block; width: 200px; min-height: 150px; border: 2px dashed #ccc; padding: 10px; margin: 10px; vertical-align: top; }
  .container.drag-over { border-color: #4a90d9; background: #f0f7ff; }
  .item { padding: 8px; background: #fff; margin: 4px 0; border: 1px solid #ddd; cursor: move; }
  .item.dragging { opacity: 0.5; }
</style>

<div id="todo" class="container" data-status="todo">
  <h3>待办</h3>
  <div class="item" draggable="true" data-id="1">任务 A</div>
  <div class="item" draggable="true" data-id="2">任务 B</div>
</div>

<div id="done" class="container" data-status="done">
  <h3>已完成</h3>
  <div class="item" draggable="true" data-id="3">任务 C</div>
</div>

<script>
  const containers = document.querySelectorAll(".container")
  let dragging = null
  let sourceContainer = null

  // 全局拖拽开始
  document.addEventListener("dragstart", (event) => {
    const item = event.target.closest(".item")
    if (!item) return
    dragging = item
    sourceContainer = item.closest(".container")
    item.classList.add("dragging")
    event.dataTransfer.effectAllowed = "move"
    event.dataTransfer.setData("text/plain", item.dataset.id)
  })

  // 全局拖拽结束
  document.addEventListener("dragend", () => {
    if (dragging) dragging.classList.remove("dragging")
    containers.forEach(c => c.classList.remove("drag-over"))
    dragging = null
    sourceContainer = null
  })

  containers.forEach(container => {
    container.addEventListener("dragenter", (event) => {
      event.preventDefault()
      container.classList.add("drag-over")
    })

    container.addEventListener("dragover", (event) => {
      event.preventDefault()
      event.dataTransfer.dropEffect = "move"
    })

    container.addEventListener("dragleave", (event) => {
      // 检查是否真正离开容器
      if (!container.contains(event.relatedTarget)) {
        container.classList.remove("drag-over")
      }
    })

    container.addEventListener("drop", (event) => {
      event.preventDefault()
      container.classList.remove("drag-over")
      if (!dragging) return

      // 执行移动
      const targetItem = event.target.closest(".item")
      if (targetItem && targetItem !== dragging) {
        const rect = targetItem.getBoundingClientRect()
        const isAfter = event.clientY > rect.top + rect.height / 2
        container.insertBefore(dragging, isAfter ? targetItem.nextSibling : targetItem)
      } else {
        container.appendChild(dragging)
      }

      // 输出状态变化
      const newStatus = container.dataset.status
      const oldStatus = sourceContainer.dataset.status
      if (newStatus !== oldStatus) {
        console.log(`任务 ${dragging.dataset.id} 从 ${oldStatus} 移动到 ${newStatus}`)
      }
    })
  })
</script>

7.7 示例七:拖放图片预览

html
<style>
  .dropzone { width: 300px; height: 200px; border: 2px dashed #ccc; display: flex; align-items: center; justify-content: center; background: #fafafa; }
  .dropzone.drag-over { border-color: #4a90d9; background: #f0f7ff; }
  .preview { max-width: 100%; max-height: 100%; object-fit: contain; }
</style>

<div id="imageDrop" class="dropzone">拖放图片到这里</div>

<script>
  const dropzone = document.getElementById("imageDrop")

  dropzone.addEventListener("dragover", (event) => {
    event.preventDefault()
    dropzone.classList.add("drag-over")
  })

  dropzone.addEventListener("dragleave", () => {
    dropzone.classList.remove("drag-over")
  })

  dropzone.addEventListener("drop", (event) => {
    event.preventDefault()
    dropzone.classList.remove("drag-over")

    const file = event.dataTransfer.files[0]
    if (!file || !file.type.startsWith("image/")) {
      alert("请拖放图片文件")
      return
    }

    // 创建预览
    const img = document.createElement("img")
    img.className = "preview"
    img.src = URL.createObjectURL(file)
    img.onload = () => URL.revokeObjectURL(img.src) // 清理内存
    dropzone.innerHTML = ""
    dropzone.appendChild(img)
  })
</script>

7.8 示例八:拖放文件夹(webkitGetAsEntry)

html
<style>
  .dropzone { padding: 20px; border: 2px dashed #ccc; min-height: 100px; }
  .dropzone.drag-over { border-color: #4a90d9; }
  #tree { font-family: monospace; white-space: pre; margin-top: 10px; }
</style>

<div id="folderDrop" class="dropzone">拖放文件夹到这里</div>
<div id="tree"></div>

<script>
  const dropzone = document.getElementById("folderDrop")
  const treeOutput = document.getElementById("tree")

  dropzone.addEventListener("dragover", (event) => {
    event.preventDefault()
    dropzone.classList.add("drag-over")
  })

  dropzone.addEventListener("dragleave", () => {
    dropzone.classList.remove("drag-over")
  })

  dropzone.addEventListener("drop", async (event) => {
    event.preventDefault()
    dropzone.classList.remove("drag-over")

    const items = event.dataTransfer.items
    const results = []

    for (const item of items) {
      const entry = item.webkitGetAsEntry?.()
      if (entry) {
        await traverseEntry(entry, "", results)
      }
    }

    treeOutput.textContent = results.join("\n")
  })

  async function traverseEntry(entry, path, results) {
    const fullPath = path + entry.name

    if (entry.isFile) {
      results.push(`📄 ${fullPath}`)
    } else if (entry.isDirectory) {
      results.push(`📁 ${fullPath}/`)
      const reader = entry.createReader()
      const entries = await new Promise((resolve) => {
        reader.readEntries(resolve)
      })
      for (const child of entries) {
        await traverseEntry(child, fullPath + "/", results)
      }
    }
  }
</script>

8. 移动端触摸事件替代方案

由于移动浏览器对原生 HTML5 拖放 API 的支持不一致,推荐使用触摸/指针事件实现移动端拖拽。

8.1 三种拖拽实现方案对比

以下图表系统性地对比了桌面端原生 DnD、移动端 Touch API 和 Pointer Events 三种方案的特性差异,帮助开发者在不同场景下做出合理的技术选型:

图表渲染中…

三种方案详细对比矩阵

特性维度HTML5 DnD APITouch EventsPointer Events
设备覆盖仅桌面端良好仅移动端全平台统一
事件模型drag* 系列 7 个事件touch* 系列 4 个事件pointer* 系列 6+ 个事件
数据传输内置 DataTransfer 对象需自行维护状态需自行维护状态
拖拽图像浏览器自动生成 / setDragImage需手动创建克隆元素需手动创建克隆元素
多点触控❌ 不支持touches 数组isPrimary 过滤
自定义光标通过 dropEffect 控制❌ 不适用❌ 不适用
iOS Safari❌ 几乎不可用✅ 可用但有限制⚠️ 部分可用(见 FAQ)
Android Chrome⚠️ 有限支持✅ 良好✅ 良好
代码复用性桌面/移动需分开实现桌面/移动需分开实现一套代码全平台
学习成本低(API 完善)中(需理解触摸机制)中(概念较新)
社区生态成熟库少(因移动端缺陷)丰富快速增长
推荐指数★★★☆☆(仅桌面)★★★☆☆(仅移动端)★★★★★(首选)

8.2 触摸事件基础

事件说明
touchstart手指触摸屏幕
touchmove手指移动
touchend手指离开屏幕
touchcancel触摸被中断(如来电)

8.3 指针事件(推荐)

指针事件统一处理鼠标、触摸、触控笔输入,兼容性更好。

事件说明
pointerdown指针按下
pointermove指针移动
pointerup指针抬起
pointercancel指针取消

8.4 移动端拖拽实现示例

html
<style>
  .draggable { width: 100px; height: 100px; background: #4a90d9; color: white; display: flex; align-items: center; justify-content: center; touch-action: none; }
  .dropzone { width: 200px; height: 200px; border: 2px dashed #ccc; margin: 20px; display: inline-block; }
  .dropzone.active { border-color: #4a90d9; background: #f0f7ff; }
</style>

<div id="draggable" class="draggable">拖拽我</div>
<div id="dropzone" class="dropzone">放置区域</div>

<script>
  const draggable = document.getElementById("draggable")
  const dropzone = document.getElementById("dropzone")

  let isDragging = false
  let startX = 0, startY = 0
  let offsetX = 0, offsetY = 0
  let clone = null
  let originalParent = null

  // 使用指针事件
  draggable.addEventListener("pointerdown", (event) => {
    isDragging = true
    draggable.setPointerCapture(event.pointerId)

    startX = event.clientX
    startY = event.clientY
    const rect = draggable.getBoundingClientRect()
    offsetX = startX - rect.left
    offsetY = startY - rect.top
    originalParent = draggable.parentElement

    // 创建拖拽副本(视觉反馈)
    clone = draggable.cloneNode(true)
    clone.style.position = "fixed"
    clone.style.left = rect.left + "px"
    clone.style.top = rect.top + "px"
    clone.style.width = rect.width + "px"
    clone.style.opacity = "0.8"
    clone.style.pointerEvents = "none"
    clone.style.zIndex = "1000"
    document.body.appendChild(clone)

    draggable.style.opacity = "0.3"
  })

  document.addEventListener("pointermove", (event) => {
    if (!isDragging || !clone) return

    clone.style.left = (event.clientX - offsetX) + "px"
    clone.style.top = (event.clientY - offsetY) + "px"

    // 检测是否在放置区域上方
    const dropRect = dropzone.getBoundingClientRect()
    const isOver = (
      event.clientX >= dropRect.left &&
      event.clientX <= dropRect.right &&
      event.clientY >= dropRect.top &&
      event.clientY <= dropRect.bottom
    )
    dropzone.classList.toggle("active", isOver)
  })

  document.addEventListener("pointerup", (event) => {
    if (!isDragging) return
    isDragging = false

    // 检测放置位置
    const dropRect = dropzone.getBoundingClientRect()
    const isDropped = (
      event.clientX >= dropRect.left &&
      event.clientX <= dropRect.right &&
      event.clientY >= dropRect.top &&
      event.clientY <= dropRect.bottom
    )

    if (isDropped) {
      dropzone.appendChild(draggable)
      dropzone.classList.remove("active")
    }

    // 清理
    draggable.style.opacity = "1"
    if (clone) {
      clone.remove()
      clone = null
    }
  })
</script>

8.5 移动端注意事项

| 问题 | 解决方案 | | --- | --- | --- | | 页面滚动冲突 | 在拖拽元素上设置 touch-action: none | | 长按触发菜单 | event.preventDefault() 或 CSS -webkit-touch-callout: none | | 多指触摸 | 检查 event.isPrimary 或跟踪触摸标识 | | 性能问题 | 使用 transform 而非 left/top 移动元素 | | 点击穿透 | 在 pointerup 后延迟 300ms 移除副本 |

8.6 桌面 + 移动端统一封装

javascript
class UnifiedDragDrop {
  constructor(options) {
    this.options = options
    this.isDragging = false
    this.supportsPointerEvents = "PointerEvent" in window
  }

  bind(element, dropzone) {
    if (this.supportsPointerEvents) {
      this.bindPointerEvents(element, dropzone)
    } else {
      this.bindMouseEvents(element, dropzone)
      this.bindTouchEvents(element, dropzone)
    }
  }

  bindPointerEvents(element, dropzone) {
    element.style.touchAction = "none"

    element.addEventListener("pointerdown", (e) => {
      if (!e.isPrimary) return
      this.startDrag(e, element)
    })

    document.addEventListener("pointermove", (e) => {
      if (!this.isDragging) return
      this.moveDrag(e, element, dropzone)
    })

    document.addEventListener("pointerup", (e) => {
      if (!this.isDragging) return
      this.endDrag(e, element, dropzone)
    })
  }

  // ... 实现细节
}

9. 可访问性实现

9.1 ARIA 属性

属性用途示例
aria-grabbed标识元素是否被拖拽"true" / "false"
aria-dropeffect预期的放置效果"copy" / "move" / "link" / "none"
aria-label描述拖拽目的"拖拽以重新排序"
role="listitem"列表项角色配合 role="list"

9.2 键盘支持

html
<style>
  .item:focus { outline: 2px solid #4a90d9; }
  .item:focus-visible { outline-offset: 2px; }
</style>

<ul role="list">
  <li class="item" draggable="true" tabindex="0" 
      role="listitem" aria-grabbed="false"
      aria-label="任务 1,使用上下箭头键移动">
    任务 1
  </li>
  <li class="item" draggable="true" tabindex="0"
      role="listitem" aria-grabbed="false">
    任务 2
  </li>
</ul>

<script>
  const list = document.querySelector("ul")
  let focusedIndex = 0

  list.addEventListener("keydown", (event) => {
    const items = Array.from(list.querySelectorAll(".item"))
    const current = document.activeElement
    const currentIndex = items.indexOf(current)

    switch (event.key) {
      case "ArrowUp":
        event.preventDefault()
        if (currentIndex > 0) {
          items[currentIndex - 1].focus()
        }
        break

      case "ArrowDown":
        event.preventDefault()
        if (currentIndex < items.length - 1) {
          items[currentIndex + 1].focus()
        }
        break

      case " ":
        // 空格键开始拖拽
        event.preventDefault()
        current.setAttribute("aria-grabbed", "true")
        current.classList.add("grabbed")
        break

      case "Enter":
        // 回车键放置
        event.preventDefault()
        const grabbed = list.querySelector(".item[aria-grabbed='true']")
        if (grabbed && grabbed !== current) {
          // 执行移动
          const isAfter = items.indexOf(grabbed) < currentIndex
          list.insertBefore(grabbed, isAfter ? current.nextSibling : current)
          grabbed.setAttribute("aria-grabbed", "false")
          grabbed.classList.remove("grabbed")

          // 通知屏幕阅读器
          announce(`已将 ${grabbed.textContent} 移动到位置 ${isAfter ? currentIndex + 1 : currentIndex}`)
        }
        break

      case "Escape":
        // 取消拖拽
        const grabbedItem = list.querySelector(".item[aria-grabbed='true']")
        if (grabbedItem) {
          grabbedItem.setAttribute("aria-grabbed", "false")
          grabbedItem.classList.remove("grabbed")
          announce("已取消拖拽")
        }
        break
    }
  })

  // 屏幕阅读器通知
  function announce(message) {
    const announcer = document.getElementById("announcer") || createAnnouncer()
    announcer.textContent = message
  }

  function createAnnouncer() {
    const el = document.createElement("div")
    el.id = "announcer"
    el.setAttribute("role", "status")
    el.setAttribute("aria-live", "polite")
    el.style.cssText = "position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;"
    document.body.appendChild(el)
    return el
  }
</script>

9.3 高对比度模式

css
@media (forced-colors: active) {
  .item[aria-grabbed="true"] {
    outline: 3px dashed Highlight;
  }

  .dropzone.drag-over {
    outline: 3px solid Highlight;
  }
}

10. 性能优化

10.1 高频事件优化

javascript
// dragover 节流 + requestAnimationFrame
let rafId = null

dropzone.addEventListener("dragover", (event) => {
  event.preventDefault()

  if (rafId) return

  rafId = requestAnimationFrame(() => {
    // 执行位置计算
    updateDragPosition(event)
    rafId = null
  })
})

dropzone.addEventListener("dragleave", () => {
  if (rafId) {
    cancelAnimationFrame(rafId)
    rafId = null
  }
})

10.2 大列表虚拟化

javascript
// 只对可见项启用拖拽
function updateDraggableState(container) {
  const items = container.querySelectorAll(".item")
  const viewportTop = window.scrollY
  const viewportBottom = viewportTop + window.innerHeight

  items.forEach(item => {
    const rect = item.getBoundingClientRect()
    const isVisible = rect.bottom > 0 && rect.top < window.innerHeight

    item.draggable = isVisible
  })
}

// 滚动时更新
window.addEventListener("scroll", () => {
  requestAnimationFrame(() => updateDraggableState(container))
}, { passive: true })

10.3 DOM 操作优化

javascript
// 使用 DocumentFragment 批量操作
function reorderItems(list, fromIndex, toIndex) {
  const items = Array.from(list.children)
  const fragment = document.createDocumentFragment()

  // 重新排序
  const [moved] = items.splice(fromIndex, 1)
  items.splice(toIndex, 0, moved)

  // 一次性更新 DOM
  items.forEach(item => fragment.appendChild(item))
  list.appendChild(fragment)
}

// 使用 CSS transform 代替 DOM 重排(动画)
function animateReorder(element, fromRect, toRect) {
  element.style.transition = "none"
  element.style.transform = `translate(${fromRect.left - toRect.left}px, ${fromRect.top - toRect.top}px)`

  requestAnimationFrame(() => {
    element.style.transition = "transform 0.15s ease"
    element.style.transform = ""
  })
}

10.4 内存管理

javascript
// 清理对象 URL
function previewFile(file) {
  const url = URL.createObjectURL(file)
  const img = new Image()

  img.onload = () => {
    // 使用完毕后立即释放
    URL.revokeObjectURL(url)
  }

  img.src = url
}

// 移除事件监听器
class DragDropManager {
  constructor() {
    this.handlers = new Map()
  }

  on(element, event, handler) {
    element.addEventListener(event, handler)
    this.handlers.set(`${element.id}-${event}`, { element, event, handler })
  }

  destroy() {
    this.handlers.forEach(({ element, event, handler }) => {
      element.removeEventListener(event, handler)
    })
    this.handlers.clear()
  }
}

11. 拖放操作性能基准测试数据

本节提供基于实际测试的性能基准数据,帮助开发者在不同规模和场景下做出合理的性能取舍。以下数据基于 Chrome 120+ / macOS / M2 芯片的测试环境采集,仅供参考,具体数值受设备性能影响。

11.1 不同列表规模下的 dragover 事件频率

在拖拽过程中,dragover 事件的触发频率直接影响排序流畅度。以下是不同列表规模下的实测数据:

列表项数量dragover 频率(次/秒)单次处理耗时(均值)CPU 占用峰值内存增量用户体验评级
10 项~60 fps0.12 ms3%< 1 MB★★★★★ 流畅
50 项~55 fps0.35 ms8%~2 MB★★★★★ 流畅
100 项~48 fps0.82 ms15%~5 MB★★★★☆ 轻微卡顿
500 项~30 fps3.5 ms35%~18 MB★★★☆☆ 明显卡顿
1000 项~18 fps8.2 ms58%~42 MB★★☆☆☆ 严重卡顿
5000 项~6 fps28 ms85%~156 MB★☆☆☆☆ 不可接受

关键发现

  • 50 项以内:无需特殊优化,原生 API 即可胜任。
  • 50–200 项:建议启用 requestAnimationFrame 节流 + 二分查找定位。
  • 200–1000 项:必须配合虚拟滚动,仅渲染可见区域。
  • 1000 项以上:强烈建议使用虚拟滚动 + Web Worker 进行离线计算。

11.2 节流策略性能对比

针对高频 dragover 事件,不同节流策略的效果差异显著:

节流策略实现方式有效帧率提升响应延迟推荐场景
无节流每次 dragover 都执行基准(0%)0ms≤ 20 项小列表
requestAnimationFrameRAF 合并到下一帧↑ 15–25%~16ms首选,大多数场景
时间节流 16msDate.now() 判断间隔↑ 20–30%0–16ms需精确控制的场景
时间节流 33ms限制为 ~30fps↑ 40–50%0–33ms大列表粗粒度更新
节流 + RAF 组合双重保障↑ 45–55%~16ms超大规模列表
javascript
// 推荐:RAF + 时间节流组合方案
class ThrottledDragHandler {
  constructor(callback, intervalMs = 16) {
    this.callback = callback
    this.intervalMs = intervalMs
    this.lastCallTime = 0
    this.rafId = null
    this.pendingArgs = null
  }

  execute(...args) {
    this.pendingArgs = args
    const now = performance.now()

    if (now - this.lastCallTime >= this.intervalMs) {
      this.flush(now)
    } else if (!this.rafId) {
      this.rafId = requestAnimationFrame((time) => {
        this.flush(time)
      })
    }
  }

  flush(time) {
    this.lastCallTime = time
    this.rafId = null
    if (this.pendingArgs) {
      this.callback(...this.pendingArgs)
      this.pendingArgs = null
    }
  }

  cancel() {
    if (this.rafId) {
      cancelAnimationFrame(this.rafId)
      this.rafId = null
    }
    this.pendingArgs = null
  }
}

// 使用
const handler = new ThrottledDragHandler(updatePosition, 16)
dropzone.addEventListener("dragover", (e) => {
  e.preventDefault()
  handler.execute(e)
})

11.3 虚拟滚动 + 拖拽排序的实现要点

当列表超过 200 项时,虚拟滚动(Virtual Scrolling)成为必需方案。以下是核心实现要点:

架构设计原则

要点说明实现建议
视口内渲染只渲染可见区域的列表项使用 IntersectionObserver 或手动计算可见范围
占位空间保持容器高度 = 总项数 × 平均高度防止滚动条跳动
拖拽项脱离虚拟池被拖拽的项必须始终存在于 DOM 中从虚拟池中提取为独立元素
滚动跟随拖拽至边缘时自动滚动列表监听边界距离,触发 scrollBy
位置映射转换虚拟索引 ↔ 实际数据索引的双向映射维护独立的 indexMap

虚拟滚动排序的核心逻辑

javascript
class VirtualSortable {
  constructor(container, options = {}) {
    this.container = container
    this.itemHeight = options.itemHeight || 48
    this.visibleCount = options.visibleCount || 15
    this.buffer = options.buffer || 5
    this.data = []         // 完整数据集
    this.visibleRange = { start: 0, end: this.visibleCount }
    this.draggingItem = null  // 脱离虚拟池的拖拽项
  }

  // 计算当前可见范围
  updateVisibleRange(scrollTop) {
    const start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer)
    const end = Math.min(
      this.data.length,
      start + this.visibleCount + this.buffer * 2
    )
    this.visibleRange = { start, end }
    this.render()
  }

  // 渲染可见项
  render() {
    const fragment = document.createDocumentFragment()

    for (let i = this.visibleRange.start; i < this.visibleRange.end; i++) {
      // 跳过正在拖拽的项(它已在 DOM 外部独立显示)
      if (this.draggingItem?.dataset.virtualIndex == i) continue

      const el = this.createItemElement(this.data[i], i)
      el.style.position = "absolute"
      el.style.top = `${i * this.itemHeight}px`
      el.style.width = "100%"
      fragment.appendChild(el)
    }

    // 保持总高度
    this.container.innerHTML = ""
    this.container.style.height = `${this.data.length * this.itemHeight}px`
    this.container.style.position = "relative"
    this.container.appendChild(fragment)

    // 将拖拽项追加到最后(z-index 最高)
    if (this.draggingItem) {
      this.container.appendChild(this.draggingItem)
    }
  }

  // 拖拽开始时将该项从虚拟池中取出
  onDragStart(index, element) {
    this.draggingItem = element.cloneNode(true)
    this.draggingItem.dataset.virtualIndex = index
    this.draggingItem.style.position = "absolute"
    this.draggingItem.style.zIndex = "1000"
    this.draggingItem.style.pointerEvents = "none"
  }

  // 拖拽结束时重新放入虚拟池
  onDragEnd(newIndex) {
    if (this.draggingItem) {
      const fromIndex = parseInt(this.draggingItem.dataset.virtualIndex)
      // 更新数据模型
      const [moved] = this.data.splice(fromIndex, 1)
      this.data.splice(newIndex, 0, moved)
      this.draggingItem = null
      this.render()
    }
  }
}

11.4 requestAnimationFrame vs setTimeout 在拖拽预览中的性能差异

指标requestAnimationFramesetTimeout(fn, 0)setTimeout(fn, 16)
调用频率与显示器刷新率同步(60/120/144Hz)取决于事件循环(可能远超 60fps)固定约 60fps
帧对齐✅ 自动对齐到垂直同步❌ 可能出现撕裂⚠️ 近似对齐
CPU 利用效率★★★★★ 高效利用空闲周期★★☆☆☆ 可能过度消耗★★★★☆ 较好
电池影响高(频繁唤醒)中等
拖拽跟随精度★★★★★ 平滑★★★☆☆ 可能抖动★★★★☆ 较平滑
内存压力中(回调堆积风险)
取消便利性cancelAnimationFrameclearTimeoutclearTimeout

结论:在拖拽预览和位置更新的场景中,requestAnimationFrame 始终是首选方案。它不仅能保证视觉平滑性,还能有效降低 CPU 占用和电池消耗。

11.5 性能监控指标建议

在实际项目中,建议建立以下性能基线监控:

指标采集方法告警阈值优化方向
dragover 处理耗时performance.now() 包裹> 10ms(P95)算法优化 / 节流
拖拽期间 FPSrequestAnimationFrame 计数< 45 fps 持续 > 1s减少 DOM 操作
堆内存增长performance.memory.usedJSHeapSize每秒增长 > 5MB检查泄漏 / 及时清理
主线程阻塞时长PerformanceObserver(Long Task)> 50ms拆分计算到 Worker
布局抖动次数DevTools Layout ShiftCLS > 0.1避免 synchronous layout
javascript
// 性能监控工具函数
class DragPerformanceMonitor {
  constructor() {
    this.metrics = {
      dragoverCount: 0,
      totalProcessTime: 0,
      maxProcessTime: 0,
      frameDrops: 0,
      startTime: 0
    }
    this.lastFrameTime = 0
  }

  startDrag() {
    this.metrics = {
      dragoverCount: 0,
      totalProcessTime: 0,
      maxProcessTime: 0,
      frameDrops: 0,
      startTime: performance.now()
    }
    this.lastFrameTime = this.metrics.startTime
  }

  recordDragover(processFn) {
    const start = performance.now()
    processFn()
    const duration = performance.now() - start

    this.metrics.dragoverCount++
    this.metrics.totalProcessTime += duration
    this.metrics.maxProcessTime = Math.max(this.metrics.maxProcessTime, duration)

    // 检测掉帧(假设目标 60fps = 16.67ms/帧)
    const now = performance.now()
    if (now - this.lastFrameTime > 20) {
      this.metrics.frameDrops++
    }
    this.lastFrameTime = now
  }

  getReport() {
    const totalDuration = performance.now() - this.metrics.startTime
    return {
      totalDragoverEvents: this.metrics.dragoverCount,
      avgProcessTime: (this.metrics.totalProcessTime / this.metrics.dragoverCount).toFixed(2) + "ms",
      maxProcessTime: this.metrics.maxProcessTime.toFixed(2) + "ms",
      estimatedFPS: (this.metrics.dragoverCount / (totalDuration / 1000)).toFixed(1),
      frameDrops: this.metrics.frameDrops,
      totalDuration: totalDuration.toFixed(0) + "ms"
    }
  }
}

12. 高级拖拽模式

12.1 多选拖拽(Multi-Select Drag)

多选拖拽允许用户通过 Shift/Ctrl(macOS 为 Cmd)+ 点击选中多个项目后一起拖拽,常见于文件管理器和邮件客户端场景。

交互设计要点

操作行为适用平台
单击选中/取消单个项目所有平台
Ctrl/Cmd + 单击切换单个项目的选中状态桌面端
Shift + 单击选中从上一个选中项到当前项的范围桌面端
Ctrl/Cmd + A全选桌面端
拖拽已选中项拖动所有选中项所有平台

完整实现示例

html
<style>
  .multi-list { list-style: none; padding: 0; }
  .multi-item {
    padding: 10px 14px;
    margin: 4px 0;
    background: #fff;
    border: 2px solid transparent;
    border-radius: 6px;
    cursor: default;
    user-select: none;
    transition: all 0.15s ease;
  }
  .multi-item.selected {
    background: #e3f2fd;
    border-color: #2196f3;
  }
  .multi-item.selected::before {
    content: "☑";
    color: #2196f3;
    margin-right: 8px;
    font-weight: bold;
  }
  .multi-item.dragging {
    opacity: 0.4;
  }
  .ghost-container {
    position: fixed;
    pointer-events: none;
    z-index: 9999;
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .ghost-item {
    background: rgba(33, 150, 243, 0.9);
    color: white;
    padding: 8px 12px;
    border-radius: 4px;
    font-size: 13px;
    white-space: nowrap;
    box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  }
  .selection-count {
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: #2196f3;
    color: white;
    padding: 8px 16px;
    border-radius: 20px;
    font-size: 14px;
    font-weight: bold;
    z-index: 1000;
    display: none;
  }
  .selection-count.visible { display: block; }
</style>

<ul id="multiList" class="multi-list">
  <li class="multi-item" draggable="true" data-id="1">文档报告.docx</li>
  <li class="multi-item" draggable="true" data-id="2">数据分析.xlsx</li>
  <li class="multi-item" draggable="true" data-id="3">项目计划.pptx</li>
  <li class="multi-item" draggable="true" data-id="4">会议纪要.pdf</li>
  <li class="multi-item" draggable="true" data-id="5">预算表格.xlsx</li>
  <li class="multi-item" draggable="true" data-id="6">合同模板.docx</li>
</ul>
<div id="selectionCount" class="selection-count">已选中 0 项</div>

<script>
  const list = document.getElementById("multiList")
  const countBadge = document.getElementById("selectionCount")
  const items = Array.from(list.querySelectorAll(".multi-item"))

  const state = {
    selected: new Set(),       // 已选中项的 ID 集合
    lastIndex: null,           // 上一次点击的索引(用于 Shift 范围选择)
    draggingItems: [],         // 正在拖拽的项目集合
    ghostEl: null              // 自定义拖拽图像容器
  }

  // 更新选中 UI
  function updateSelectionUI() {
    items.forEach(item => {
      item.classList.toggle("selected", state.selected.has(item.dataset.id))
    })
    const count = state.selected.size
    countBadge.textContent = `已选中 ${count} 项`
    countBadge.classList.toggle("visible", count > 0)
  }

  // 点击选择逻辑
  list.addEventListener("mousedown", (e) => {
    const item = e.target.closest(".multi-item")
    if (!item) return
    const id = item.dataset.id
    const index = items.indexOf(item)
    const isMetaKey = e.ctrlKey || e.metaKey
    const isShiftKey = e.shiftKey

    if (isShiftKey && state.lastIndex !== null) {
      // Shift + 点击:范围选择
      const start = Math.min(state.lastIndex, index)
      const end = Math.max(state.lastIndex, index)
      for (let i = start; i <= end; i++) {
        state.selected.add(items[i].dataset.id)
      }
    } else if (isMetaKey) {
      // Ctrl/Cmd + 点击:切换选中
      if (state.selected.has(id)) {
        state.selected.delete(id)
      } else {
        state.selected.add(id)
      }
    } else {
      // 普通单击:单选
      state.selected.clear()
      state.selected.add(id)
    }

    state.lastIndex = index
    updateSelectionUI()
  })

  // 拖拽开始
  list.addEventListener("dragstart", (e) => {
    const item = e.target.closest(".multi-item")
    if (!item) return
    const id = item.dataset.id

    // 如果点击的项未被选中,则只拖拽这一项
    if (!state.selected.has(id)) {
      state.selected.clear()
      state.selected.add(id)
      updateSelectionUI()
    }

    // 收集所有选中项
    state.draggingItems = items.filter(i => state.selected.has(i.dataset.id))

    // 设置拖拽数据(传递所有选中 ID)
    e.dataTransfer.setData(
      "application/json",
      JSON.stringify({
        type: "multi-select",
        ids: [...state.selected]
      })
    )
    e.dataTransfer.effectAllowed = "move"

    // 创建自定义多选拖拽图像
    state.ghostEl = document.createElement("div")
    state.ghostEl.className = "ghost-container"

    const displayItems = state.draggingItems.slice(0, 5) // 最多显示 5 个
    displayItems.forEach((dragItem, idx) => {
      const ghost = document.createElement("div")
      ghost.className = "ghost-item"
      ghost.textContent = dragItem.textContent.trim()
      if (idx === 0) ghost.style.fontWeight = "bold"
      state.ghostEl.appendChild(ghost)
    })

    if (state.draggingItems.length > 5) {
      const more = document.createElement("div")
      more.className = "ghost-item"
      more.textContent = `... 还有 ${state.draggingItems.length - 5} 项`
      more.style.fontStyle = "italic"
      state.ghostEl.appendChild(more)
    }

    document.body.appendChild(state.ghostEl)
    e.dataTransfer.setDragImage(state.ghostEl, 60, 20)

    // 标记拖拽态
    state.draggingItems.forEach(i => i.classList.add("dragging"))
  })

  // 拖拽结束清理
  list.addEventListener("dragend", () => {
    state.draggingItems.forEach(i => i.classList.remove("dragging"))
    state.draggingItems = []
    if (state.ghostEl) {
      state.ghostEl.remove()
      state.ghostEl = null
    }
  })

  // 放置处理(以另一个列表为例)
  list.addEventListener("drop", (e) => {
    e.preventDefault()
    try {
      const data = JSON.parse(e.dataTransfer.getData("application/json"))
      console.log(`放置了 ${data.ids.length} 个项目:`, data.ids)
      // 这里可以执行实际的批量移动操作
    } catch (err) {
      console.error("解析拖拽数据失败", err)
    }
  })

  list.addEventListener("dragover", (e) => e.preventDefault())
</script>

12.2 跨窗口/跨 iframe 拖拽的限制与解决方案

跨窗口和跨 iframe 的拖拽是一个高级且常被忽视的场景,涉及同源策略、安全沙箱和数据隔离等多个层面。

限制分析

限制维度同源 iframe跨源 iframe新窗口(window.open)外部应用
DataTransfer 数据互通✅ 支持❌ 受限(仅 Files)✅ 支持❌ 不支持
dragover/drop 事件穿透✅ 正常冒泡⚠️ 受 sandbox 属性影响❌ 独立事件循环❌ 不适用
setDragImage 跨域✅ 支持❌ 图像不显示✅ 支持❌ 不适用
effectAllowed 传递✅ 支持⚠️ 部分浏览器丢失✅ 支持❌ 不适用
安全策略同源策略CORS + CSP同源策略操作系统级别

同源 iframe 拖拽方案

javascript
/**
 * 同源 iframe 之间的拖拽通信
 * 原理:DataTransfer 在同源 iframe 间可以正常传递字符串数据
 */

// 父页面:发起拖拽
const parentSource = document.getElementById("parent-draggable")
parentSource.addEventListener("dragstart", (e) => {
  e.dataTransfer.setData("application/json", JSON.stringify({
    source: "parent-frame",
    id: "item-123",
    timestamp: Date.now(),
    // 可以包含任何可序列化的数据
  }))
  e.dataTransfer.effectAllowed = "copy"
})

// 子 iframe 页面:接收拖拽
// (子 iframe 内部的代码)
const childDropzone = document.getElementById("child-dropzone")
childDropzone.addEventListener("dragover", (e) => {
  e.preventDefault()
  e.dataTransfer.dropEffect = "copy"
})

childDropzone.addEventListener("drop", (e) => {
  e.preventDefault()
  const rawData = e.dataTransfer.getData("application/json")
  if (rawData) {
    const payload = JSON.parse(rawData)
    console.log("收到来自父页面的数据:", payload)
    // payload.source === "parent-frame" → 确认来源
  }
})

跨源 iframe 拖拽方案(postMessage 桥接)

由于跨源 iframe 的 DataTransfer 字符串数据无法直接读取,需要借助 postMessage 作为数据桥接通道:

javascript
/**
 * 跨源 iframe 拖拽桥接方案
 * 
 * 架构:
 *   父页面 ──dragstart──→ [DataTransfer 仅携带标识] ──→ 子 iframe dragenter
 *   父页面 ──postMessage──→ [完整数据] ───────────────→ 子 iframe message 事件
 *   子 iframe ──postMessage──→ [确认接收] ─────────────→ 父页面
 */

// ========== 父页面代码 ==========

const crossOriginIframe = document.getElementById("cross-origin-iframe")
const pendingDragData = new Map()  // 存储待传输的拖拽数据

// 1. 拖拽开始:写入唯一标识到 DataTransfer
document.addEventListener("dragstart", (e) => {
  const item = e.target.closest("[draggable]")
  if (!item) return

  const transferId = `transfer-${Date.now()}-${Math.random().toString(36).slice(2)}`
  const fullData = {
    id: item.dataset.id,
    type: item.dataset.type,
    content: item.textContent.trim(),
    metadata: { /* 复杂对象 */ }
  }

  // 存储完整数据到本地 Map
  pendingDragData.set(transferId, fullData)

  // DataTransfer 只写入 ID(跨域也可传递短字符串)
  e.dataTransfer.setData("text/plain", transferId)
  e.dataTransfer.effectAllowed = "copy"

  // 通过 postMessage 发送完整数据到 iframe
  try {
    crossOriginIframe.contentWindow.postMessage(
      { type: "DRAG_DATA_PREPARE", transferId, data: fullData },
      "*"  // 生产环境应指定目标 origin
    )
  } catch (err) {
    console.warn("postMessage 发送失败(iframe 可能未加载):", err)
  }
})

// 2. 监听 iframe 的确认消息
window.addEventListener("message", (e) => {
  if (e.data.type === "DROP_CONFIRMED") {
    const { transferId } = e.data
    console.log(`子 iframe 确认接收了数据: ${transferId}`)
    pendingDragData.delete(transferId) // 清理已消费的数据
  }
})

// 3. 定期清理过期数据(防止内存泄漏)
setInterval(() => {
  const now = Date.now()
  for (const [id, data] of pendingDragData) {
    if (now - data.timestamp > 60000) { // 60 秒过期
      pendingDragData.delete(id)
    }
  }
}, 30000)


// ========== 子 iframe 页面代码 ==========

const receivedDataCache = new Map()

// 1. 监听来自父页面的拖拽数据
window.addEventListener("message", (e) => {
  // 生产环境:验证 e.origin
  if (e.data.type === "DRAG_DATA_PREPARE") {
    const { transferId, data } = e.data
    receivedDataCache.set(transferId, {
      ...data,
      receivedAt: Date.now()
    })
  }
})

// 2. 放置区域处理
const dropzone = document.getElementById("cross-origin-dropzone")
dropzone.addEventListener("dragover", (e) => {
  e.preventDefault()
  e.dataTransfer.dropEffect = "copy"
})

dropzone.addEventListener("drop", (e) => {
  e.preventDefault()

  // 从 DataTransfer 读取 transferId
  const transferId = e.dataTransfer.getData("text/plain")
  if (!transferId) {
    console.warn("未获取到传输标识")
    return
  }

  // 从缓存中获取完整数据
  const fullData = receivedDataCache.get(transferId)
  if (!fullData) {
    console.error("未找到对应的拖拽数据,可能 postMessage 未到达")
    return
  }

  console.log("接收到完整的跨域拖拽数据:", fullData)

  // 通知父页面数据已接收
  try {
    window.parent.postMessage(
      { type: "DROP_CONFIRMED", transferId },
      "*"
    )
  } catch (err) {
    // 忽略
  }

  receivedDataCache.delete(transferId)
})

跨源 iframe 的安全注意事项

安全风险防护措施
恶意 iframe 窃取数据使用 sandbox="allow-same-origin" 限制权限;postMessage 指定 targetOrigin
伪造拖拽数据在消息接收端验证 event.origin 白名单
数据缓存溢出攻击设置 TTL 自动清理缓存;限制缓存条目上限
Clickjacking 结合拖拽在敏感操作处要求二次确认

12.3 拖拽撤销/重做(Undo/Redo)集成

对于看板、列表排序等涉及数据变更的拖拽场景,集成 Undo/Redo 机制可以大幅提升用户体验,防止误操作的损失。

命令模式(Command Pattern)实现

javascript
/**
 * 基于 Command Pattern 的拖拽 Undo/Redo 管理
 *
 * 设计思路:
 * - 每次拖拽操作产生一个 Command 对象
 * - Command 包含 execute() 和 undo() 方法
 * - CommandStack 维护操作历史栈和重做栈
 */

// ========== 基础 Command 类 ==========

class DragCommand {
  /**
   * @param {Object} params
   * @param {string} params.id - 唯一标识
   * @param {string} params.description - 操作描述(用于 UI 显示)
   * @param {Function} params.execute - 执行操作
   * @param {Function} params.undo - 撤销操作
   */
  constructor({ id, description, execute, undo }) {
    this.id = id
    this.description = description
    this.timestamp = Date.now()
    this._execute = execute
    this._undo = undo
  }

  execute() { return this._execute() }
  undo() { return this._undo() }
}


// ========== Command Stack(操作历史管理)==========

class DragCommandStack {
  constructor(maxHistory = 50) {
    this.undoStack = []
    this.redoStack = []
    this.maxHistory = maxHistory
    this.listeners = new Set()
  }

  // 执行一个新命令
  execute(command) {
    const result = command.execute()
    this.undoStack.push(command)
    this.redoStack = [] // 新操作清空重做栈

    // 限制历史栈大小
    if (this.undoStack.length > this.maxHistory) {
      this.undoStack.shift()
    }

    this.notify("executed", command)
    return result
  }

  // 撤销
  undo() {
    const command = this.undoStack.pop()
    if (!command) return null

    command.undo()
    this.redoStack.push(command)
    this.notify("undone", command)
    return command
  }

  // 重做
  redo() {
    const command = this.redoStack.pop()
    if (!command) return null

    command.execute()
    this.undoStack.push(command)
    this.notify("redone", command)
    return command
  }

  // 状态查询
  canUndo() { return this.undoStack.length > 0 }
  canRedo() { return this.redoStack.length > 0 }
  getUndoDescription() { return this.undoStack[this.undoStack.length - 1]?.description }
  getRedoDescription() { return this.redoStack[this.redoStack.length - 1]?.description }

  // 事件订阅
  onChange(callback) {
    this.listeners.add(callback)
    return () => this.listeners.delete(callback)
  }

  notify(action, command) {
    this.listeners.forEach(cb => cb({
      action,
      command,
      canUndo: this.canUndo(),
      canRedo: this.canRedo(),
      undoDesc: this.getUndoDescription(),
      redoDesc: this.getRedoDescription()
    }))
  }

  // 清空历史
  clear() {
    this.undoStack = []
    this.redoStack = []
    this.notify("cleared", null)
  }
}


// ========== 实际集成示例 ==========

// 初始化命令栈
const commandStack = new DragCommandStack(30)

// 列表排序的拖拽集成
const sortableList = document.getElementById("undo-sortable")
let listState = ["A", "B", "C", "D", "E"] // 数据模型

function renderList() {
  sortableList.innerHTML = ""
  listState.forEach((item, index) => {
    const li = document.createElement("li")
    li.draggable = true
    li.dataset.index = index
    li.textContent = `项目 ${item}`
    li.style.cssText = "padding: 8px; background: #f5f5f5; margin: 2px 0;"
    sortableList.appendChild(li)
  })
}

renderList()

let dragSourceIndex = null

sortableList.addEventListener("dragstart", (e) => {
  const item = e.target.closest("li[draggable]")
  if (item) dragSourceIndex = parseInt(item.dataset.index)
})

sortableList.addEventListener("dragover", (e) => {
  e.preventDefault()
})

sortableList.addEventListener("drop", (e) => {
  e.preventDefault()
  const targetItem = e.target.closest("li[draggable]")
  if (!targetItem || dragSourceIndex === null) return

  const targetIndex = parseInt(targetItem.dataset.index)
  if (dragSourceIndex === targetIndex) return

  // 创建撤销命令
  const command = new DragCommand({
    id: `sort-${Date.now()}`,
    description: `移动「${listState[dragSourceIndex]}」从位置 ${dragSourceIndex + 1} 到 ${targetIndex + 1}`,

    execute: () => {
      const [moved] = listState.splice(dragSourceIndex, 1)
      // 如果是从前往后拖,targetIndex 需要减 1(因为已经删除了一个元素)
      const insertAt = dragSourceIndex < targetIndex ? targetIndex - 1 : targetIndex
      listState.splice(insertAt, 0, moved)
      renderList()
      return listState.slice()
    },

    undo: () => {
      // 反向操作:恢复原始顺序
      // 这里简化处理:重新加载数据快照
      // 实际项目中应在 execute 时保存快照
      renderList()
      return listState.slice()
    }
  })

  commandStack.execute(command)
  dragSourceIndex = null
})


// ========== 键盘快捷键绑定 ==========

document.addEventListener("keydown", (e) => {
  // Ctrl+Z / Cmd+Z:撤销
  if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
    e.preventDefault()
    commandStack.undo()
  }

  // Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y:重做
  if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
    e.preventDefault()
    commandStack.redo()
  }
})


// ========== UI 状态栏(可选)==========

commandStack.onChange((state) => {
  console.log(
    `[拖拽历史] 操作: ${state.action}`,
    `| 可撤销: ${state.canUndo ? state.undoDesc : '无'}`,
    `| 可重做: ${state.canRedo ? state.redoDesc : '无'}`
  )

  // 可在此更新工具栏按钮的 disabled 状态
  const undoBtn = document.getElementById("undo-btn")
  const redoBtn = document.getElementById("redo-btn")
  if (undoBtn) undoBtn.disabled = !state.canUndo
  if (redoBtn) redoBtn.disabled = !state.canRedo
})

Undo/Redo 最佳实践

实践说明
保存完整快照execute 中深拷贝当前状态,确保 undo 能准确还原
合并连续同类操作如连续多次微调位置,可合并为一个命令(如 Photoshop 的历史记录)
设置合理的历史上限建议 30–50 条,过多会占用大量内存
服务端协同协作编辑场景需考虑操作转换(OT)或 CRDT 算法
持久化关键操作重要拖拽操作(如删除)即使刷新页面也应可撤销,需存入 localStorage 或服务端

12.4 键盘辅助拖拽(方向键微调位置)

对于精细排版、图表节点调整等场景,纯鼠标拖拽难以达到像素级精准。键盘辅助拖拽允许用户在选中元素后,通过方向键进行微调。

交互设计规范

按键功能默认步长按住 Shift按住 Ctrl/Cmd
/ / / 向对应方向微调位置1 px10 px(粗调)0.1 px(精调)
Enter确认当前位置
Escape取消并还原到初始位置
Tab切换到下一个可拖拽元素

完整实现示例

html
<style>
  .grid-container {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
    padding: 20px;
    min-height: 400px;
    background: #f0f0f0;
    position: relative;
  }
  .adjustable-card {
    background: white;
    border: 2px solid #ddd;
    border-radius: 8px;
    padding: 16px;
    text-align: center;
    cursor: grab;
    position: relative;
    transition: box-shadow 0.15s;
    z-index: 1;
  }
  .adjustable-card:focus {
    outline: none;
    border-color: #2196f3;
    box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.3);
    z-index: 10;
  }
  .adjustable-card.adjusting {
    border-color: #ff9800;
    box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3);
  }
  .position-badge {
    position: absolute;
    top: -8px;
    right: -8px;
    background: #ff9800;
    color: white;
    font-size: 11px;
    padding: 2px 6px;
    border-radius: 10px;
    display: none;
    font-family: monospace;
  }
  .adjustable-card.adjusting .position-badge { display: block; }
  .hint-bar {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: #333;
    color: white;
    padding: 10px 20px;
    font-size: 13px;
    display: none;
    z-index: 1000;
  }
  .hint-bar.visible { display: flex; gap: 20px; align-items: center; }
  .hint-bar kbd {
    background: #555;
    padding: 2px 6px;
    border-radius: 3px;
    font-family: inherit;
    font-size: 12px;
  }
</style>

<div class="grid-container" id="gridContainer">
  <!-- 卡片由 JS 动态生成 -->
</div>
<div class="hint-bar" id="hintBar">
  <span><kbd>↑↓←→</kbd> 微调位置</span>
  <span><kbd>Shift</kbd> + 方向键 = 10px</span>
  <span><kbd>Ctrl</kbd> + 方向键 = 0.1px</span>
  <span><kbd>Enter</kbd> 确认 · <kbd>Esc</kbd> 取消</span>
</div>

<script>
  const container = document.getElementById("gridContainer")
  const hintBar = document.getElementById("hintBar")

  // 卡片数据
  const cards = [
    { id: "card-1", title: "标题卡片", color: "#e3f2fd" },
    { id: "card-2", title: "图片组件", color: "#fff3e0" },
    { id: "card-3", title: "数据表格", color: "#e8f5e9" },
    { id: "card-4", title: "统计图表", color: "#fce4ec" },
    { id: "card-5", title: "导航菜单", color: "#f3e5f5" },
    { id: "card-6", title: "表单区域", color: "#e0f7fa" },
  ]

  // 渲染卡片
  cards.forEach(card => {
    const el = document.createElement("div")
    el.className = "adjustable-card"
    el.tabIndex = 0
    el.dataset.id = card.id
    el.style.background = card.color
    el.innerHTML = `
      ${card.title}
      <div class="position-badge">0, 0</div>
    `
    container.appendChild(el)
  })

  const cardElements = Array.from(container.querySelectorAll(".adjustable-card"))

  // 键盘辅助拖拽状态
  const adjustState = {
    activeCard: null,
    initialTransform: { x: 0, y: 0 },  // 进入调整模式时的偏移量
    currentOffset: { x: 0, y: 0 },     // 当前额外偏移量
    baseRect: null                      // 初始位置的 bounding rect
  }

  // 获取步长
  function getStepSize(e) {
    if (e.ctrlKey || e.metaKey) return 0.1   // 精调
    if (e.shiftKey) return 10                // 粗调
    return 1                                 // 默认
  }

  // 更新位置显示
  function updatePositionDisplay(card) {
    const badge = card.querySelector(".position-badge")
    if (badge) {
      badge.textContent = `${adjustState.currentOffset.x.toFixed(1)}, ${adjustState.currentOffset.y.toFixed(1)}`
    }
  }

  // 应用变换
  function applyTransform(card) {
    const totalX = adjustState.initialTransform.x + adjustState.currentOffset.x
    const totalY = adjustState.initialTransform.y + adjustState.currentOffset.y
    card.style.transform = `translate(${totalX}px, ${totalY}px)`
    updatePositionDisplay(card)
  }

  // 进入调整模式
  function enterAdjustMode(card) {
    if (adjustState.activeCard === card) return

    // 如果已有其他卡片在调整模式,先退出
    if (adjustState.activeCard) {
      exitAdjustMode(false) // 不确认
    }

    adjustState.activeCard = card
    adjustState.baseRect = card.getBoundingClientRect()

    // 解析已有的 transform
    const style = window.getComputedStyle(card)
    const matrix = new DOMMatrix(style.transform)
    adjustState.initialTransform = { x: matrix.m41, y: matrix.m42 }
    adjustState.currentOffset = { x: 0, y: 0 }

    card.classList.add("adjusting")
    hintBar.classList.add("visible")
    applyTransform(card)
    announceToScreenReader(`已进入位置调整模式,使用方向键微调「${card.textContent.trim()}」的位置`)
  }

  // 退出调整模式
  function exitAdjustMode(confirm = true) {
    const card = adjustState.activeCard
    if (!card) return

    if (!confirm) {
      // 取消:还原到初始位置
      adjustState.currentOffset = { x: 0, y: 0 }
      applyTransform(card)
      announceToScreenReader(`已取消调整,「${card.textContent.trim()}」已还原`)
    } else {
      announceToScreenReader(
        `已确认位置,最终偏移: (${adjustState.currentOffset.x.toFixed(1)}, ${adjustState.currentOffset.y.toFixed(1)})`
      )
    }

    card.classList.remove("adjusting")
    adjustState.activeCard = null
    hintBar.classList.remove("visible")
  }

  // 全局键盘事件监听
  document.addEventListener("keydown", (e) => {
    const card = adjustState.activeCard
    if (!card) return

    const step = getStepSize(e)

    switch (e.key) {
      case "ArrowUp":
        e.preventDefault()
        adjustState.currentOffset.y -= step
        applyTransform(card)
        break

      case "ArrowDown":
        e.preventDefault()
        adjustState.currentOffset.y += step
        applyTransform(card)
        break

      case "ArrowLeft":
        e.preventDefault()
        adjustState.currentOffset.x -= step
        applyTransform(card)
        break

      case "ArrowRight":
        e.preventDefault()
        adjustState.currentOffset.x += step
        applyTransform(card)
        break

      case "Enter":
        e.preventDefault()
        exitAdjustMode(true)
        break

      case "Escape":
        e.preventDefault()
        exitAdjustMode(false)
        break
    }
  })

  // Tab 键切换卡片时退出当前调整模式
  container.addEventListener("keydown", (e) => {
    if (e.key === "Tab" && adjustState.activeCard) {
      // Tab 会自然切换焦点,这里只需在 blur 时处理
    }
  })

  // Focus 进入调整模式
  cardElements.forEach(card => {
    card.addEventListener("focus", () => enterAdjustMode(card))
    card.addEventListener("blur", (e) => {
      // 只有当焦点完全离开卡片时才退出
      if (adjustState.activeCard === card && !card.contains(e.relatedTarget)) {
        // 延迟退出,给 Enter/Esc 键留出处理时间
        setTimeout(() => {
          if (document.activeElement !== card) {
            exitAdjustMode(true) // 失焦时自动确认
          }
        }, 100)
      }
    })
  })

  // 屏幕阅读器通知
  function announceToScreenReader(message) {
    let announcer = document.getElementById("sr-announcer")
    if (!announcer) {
      announcer = document.createElement("div")
      announcer.id = "sr-announcer"
      announcer.setAttribute("role", "status")
      announcer.setAttribute("aria-live", "polite")
      announcer.setAttribute("aria-atomic", "true")
      announcer.style.cssText =
        "position:fixed;left:-9999px;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);"
      document.body.appendChild(announcer)
    }
    announcer.textContent = message
  }
</script>

13. 调试技巧

13.1 拖放事件日志

javascript
// 开发环境启用拖放调试
function enableDragDebug() {
  const events = ["dragstart", "drag", "dragend", "dragenter", "dragover", "dragleave", "drop"]

  events.forEach(eventType => {
    document.addEventListener(eventType, (event) => {
      console.log(`[${eventType}]`, {
        target: event.target,
        relatedTarget: event.relatedTarget,
        dataTransfer: {
          types: [...(event.dataTransfer?.types || [])],
          dropEffect: event.dataTransfer?.dropEffect,
          effectAllowed: event.dataTransfer?.effectAllowed,
          files: event.dataTransfer?.files.length
        },
        coords: { x: event.clientX, y: event.clientY }
      })
    }, true) // 捕获阶段
  })
}

// 生产环境移除
if (process.env.NODE_ENV === "development") {
  enableDragDebug()
}

13.2 可视化拖放区域

javascript
// 高亮所有拖放目标
function visualizeDropzones() {
  document.querySelectorAll("[data-dropzone]").forEach(el => {
    el.style.outline = "2px dashed red"
    el.style.outlineOffset = "2px"
  })

  document.querySelectorAll("[draggable]").forEach(el => {
    el.style.outline = "2px solid blue"
    el.style.outlineOffset = "2px"
  })
}

13.3 模拟拖放事件

javascript
// 用于自动化测试
function simulateDragDrop(source, target) {
  const dataTransfer = new DataTransfer()

  // 模拟 dragstart
  const dragStartEvent = new DragEvent("dragstart", {
    bubbles: true,
    cancelable: true,
    dataTransfer
  })
  source.dispatchEvent(dragStartEvent)

  // 模拟 dragover
  const dragOverEvent = new DragEvent("dragover", {
    bubbles: true,
    cancelable: true,
    dataTransfer
  })
  target.dispatchEvent(dragOverEvent)

  // 模拟 drop
  const dropEvent = new DragEvent("drop", {
    bubbles: true,
    cancelable: true,
    dataTransfer
  })
  target.dispatchEvent(dropEvent)

  // 模拟 dragend
  const dragEndEvent = new DragEvent("dragend", {
    bubbles: true,
    cancelable: true,
    dataTransfer
  })
  source.dispatchEvent(dragEndEvent)
}

13.4 常见调试检查项

| 问题 | 检查方法 | | --- | --- | --- | | drop 事件不触发 | 检查 dragover 是否调用了 preventDefault() | | 数据丢失 | 确认 setDatagetData 使用相同的 MIME 类型 | | 拖拽不启动 | 检查 draggable 属性是否为 "true"(字符串) | | 光标不变化 | 检查 effectAlloweddropEffect 是否匹配 | | 子元素干扰 | 使用 event.target.closest() 获取正确目标 | | 内存泄漏 | 确保移除事件监听器和清理对象 URL |


14. 常见错误与修正

常见问题错误/模糊点正确做法
放置不生效未在 dragoverpreventDefault()在目标区域 dragover/drop 都调用 preventDefault()
拖放数据为空setData/getData 类型不一致统一 MIME 类型,如 application/json
排序抖动dragover 执行高成本计算节流计算并最小化 DOM 变更
页面直接打开文件未拦截页面级 drop 默认行为document 统一拦截 dragover/drop
移动端无法使用误以为原生 DnD 在手机端一致可用使用触摸/指针事件或第三方库
dragleave 闪烁子元素触发父元素 dragleave使用计数器或 relatedTarget 过滤
拖拽图像不显示图像元素不在 DOM 中或被隐藏确保图像元素可见且已渲染
effectAllowed 无效在错误的事件中设置dragstart 事件中设置

15. 安全与性能建议

15.1 安全

  • 不信任拖放内容:对 text/html 数据先做净化(XSS 防护)。
  • 文件拖放上传必须在服务端二次校验:类型、大小与内容签名。
  • 跨区域拖放数据建立白名单策略:避免越权操作。
  • 上传接口启用鉴权、限流、防重放机制
  • 敏感操作需二次确认:如拖拽删除。

15.2 性能

  • 避免在 drag/dragover 中执行复杂逻辑或频繁重绘。
  • 列表排序优先更新内存状态,再批量提交视图变更。
  • 文件预览优先使用对象 URL,上传完毕后及时清理缓存和监听器。
  • 使用 CSS will-change 提示浏览器优化拖拽动画。

16. 浏览器兼容性与移动端策略

能力桌面浏览器移动浏览器建议
HTML5 原生拖放(元素)支持较好支持有限且差异大复杂场景优先用指针事件方案
文件拖放(DataTransfer.files支持较好部分支持提供 <input type="file"> 回退入口
setDragImage支持支持不稳定作为增强能力,不作为关键依赖
列表排序交互可用原生体验一般移动端建议使用专用排序库
DataTransferItemList现代浏览器支持良好支持较好优先使用,提供 files 回退

说明:发布前需基于目标设备与浏览器版本进行实测回归。


17. 主流拖拽库对比

在实际项目开发中,根据场景复杂度和团队技术栈选择合适的拖拽库至关重要。以下是目前主流拖拽库的系统对比:

17.1 库选型速查表

库名GitHub Stars包大小(gzip)核心定位桌面 DnD移动端虚拟滚动TypeScript许可证
SortableJS~32k~9KB列表排序✅ Touch✅ 插件MIT
@dnd-kit/core~12k~8KB现代化 DnD✅ Pointer✅ Pointer✅ 内建BSD-3
draggable (Shopify)~22k~15KB全面拖拽框架✅ Pointer✅ 插件MIT
react-beautiful-dnd~30k~18KBReact 列表排序⚠️ 有限Apache-2.0
vue-draggable-plus~4k~12KBVue3 拖拽✅ SortableJSMIT
interact.js~12k~25KB手势/拖拽/缩放MIT
html5dnd.js~0.5k~3KB原生 PolyfillMIT

17.2 详细对比分析

SortableJS —— 列表排序的首选

优势

  • 成熟稳定,生产环境经过大量验证
  • 丰富的插件生态(MultiDrag、Swap、Plugin 支持)
  • 支持嵌套排序、多列网格排序
  • 与 Vue/React/Angular/Svelte 均有官方封装

劣势

  • 基于 HTML5 DnD API,移动端依赖 Touch 事件 polyfill
  • 自定义拖拽手柄配置相对繁琐
  • 不适用于自由画布拖拽场景

适用场景:看板(Kanban)、任务列表排序、文件队列重排

javascript
import Sortable from "sortablejs"

new Sortable(document.getElementById("myList"), {
  animation: 150,
  handle: ".drag-handle",
  group: "shared",
  multiDrag: true,           // 多选拖拽插件
  selectedClass: "selected",
  onEnd: function(evt) {
    console.log("从", evt.oldIndex, "移动到", evt.newIndex)
  }
})

@dnd-kit/core —— 现代化方案的新星

优势

  • 基于 Pointer Events,天然支持桌面 + 移动端
  • 内建无障碍支持(ARIA 属性 + 键盘操作)
  • 模块化设计,按需引入
  • React/Vue 封装完善(@dnd-kit/modular

劣势

  • 社区生态较新,第三方教程较少
  • 部分高级功能(如碰撞检测)需自行实现
  • 版本迭代较快,API 偶有 breaking change

适用场景:需要同时支持桌面和移动端的现代 Web 应用、注重可访问性的产品

jsx
import { DndContext, useSensor, useSensors, PointerSensor } from "@dnd-kit/core"

function App() {
  const sensors = useSensors(useSensor(PointerSensor))

  return (
    <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
      {/* 可拖拽内容 */}
    </DndContext>
  )
}

Shopify Draggable —— 企业级全面方案

优势

  • Shopify 出品,企业级质量保证
  • 支持Sortable、Swappable、Droppable、Collidable 四种模式
  • 内建碰撞检测、吸附对齐、网格约束
  • 完善的插件系统和扩展能力

劣势

  • 包体积较大(完整版 ~15KB gzipped)
  • 学习曲线较陡峭
  • 社区活跃度一般

适用场景:复杂的仪表盘编辑器、低代码平台的组件拖放、需要多种拖拽模式的综合应用

react-beautiful-dnd (Atlassian) —— React 生态经典

优势

  • Atlassian(Jira/Trello 团队)出品,经过海量用户验证
  • API 设计优雅,符合 React 思维模式
  • 动画效果精美,开箱即用

劣势

  • 已停止维护(Archived 状态),不再接受 PR
  • 不支持虚拟滚动(长列表性能瓶颈)
  • 移动端支持有限
  • 仅支持 React

适用场景:现有项目维护(不建议新项目采用);对于新 React 项目推荐迁移至 @dnd-kit

html5dnd.js —— 轻量级 Polyfill

优势

  • 极小的包体积(~3KB gzipped)
  • 零依赖,专注于补齐浏览器差异
  • 适用于只需要基础拖放能力的场景

劣势

  • 功能单一,无排序/碰撞等高级能力
  • 维护不活跃
  • 不提供移动端支持

适用场景:老旧浏览器兼容、轻量级拖放增强

17.3 选型决策流程

项目特征推荐方案理由
纯列表/看板排序SortableJS成熟、插件丰富、社区庞大
现代 SPA(React/Vue)+ 移动端@dnd-kitPointer Events 统一双端、内置 a11y
复杂拖拽(画布/碰撞/吸附)Shopify Draggable功能最全面
现有 r-b-d 项目维护继续使用 r-b-d迁移成本高,暂无必要替换
轻量需求 / Polyfillhtml5dnd.js最小化依赖
Vue 3 项目vue-draggable-plusSortableJS 的 Vue 3 封装
自研需求参考 interact.js底层手势抽象,灵活度高

17.4 自研 vs 引入库的权衡

因素自研引入库
开发速度慢(需处理各种边界情况)快(API 即插即用)
包体积可精确控制取决于库的大小
定制灵活性完全可控受限于库的 API 设计
长期维护成本高(Bug 修复/兼容性跟进)低(社区共同维护)
团队学习成本低(自己写的最懂)中(需阅读文档和源码)
无障碍支持需自行实现主流库通常已内置

一般建议:除非有非常特殊的定制需求(如特定领域的专业工具),否则优先选用成熟的开源库。自研拖拽系统的隐性成本往往被低估——尤其是移动端兼容、无障碍支持和边界 Bug 修复方面。


18. FAQ

Q1:为什么 drop 事件不触发?

最常见原因是没有在放置区域 dragover 中调用 preventDefault()。其他可能原因:

  • 拖拽源设置了 draggable="false"
  • dragstart 中调用了 preventDefault()
  • 事件被父元素阻止

Q2:为什么 event.dataTransfer.files 是空的?

如果拖的是页面元素而不是文件,files 会为空。应根据 types 区分数据来源:

javascript
if (event.dataTransfer.types.includes("Files")) {
  // 处理文件
} else {
  // 处理文本数据
}

Q3:能否只用拖放,不提供文件选择按钮?

不建议。应保留 <input type="file"> 作为键盘与移动端回退方案,满足可访问性要求。

Q4:如何限制只能"移动"不能"复制"?

dragstarteffectAllowed="move",并在目标 dragoverdropEffect="move"。注意这仅是视觉提示,实际行为需自行实现。

Q5:为什么 dragleave 会频繁触发导致闪烁?

鼠标在目标子节点间移动也可能触发。可通过计数器或 relatedTarget 过滤:

javascript
dropzone.addEventListener("dragleave", (event) => {
  if (!dropzone.contains(event.relatedTarget)) {
    dropzone.classList.remove("drag-over")
  }
})

Q6:拖放上传是否可以替代服务端校验?

不可以。前端校验只提升体验,安全校验必须在服务端执行。前端校验可被绕过。

Q7:如何实现拖放复制而非移动?

javascript
// 在 drop 中克隆元素而非移动
dropzone.addEventListener("drop", (event) => {
  event.preventDefault()
  const clone = dragging.cloneNode(true)
  dropzone.appendChild(clone)
  // 不移除原元素
})

Q8:如何检测拖放是否来自外部(如桌面文件)?

javascript
element.addEventListener("dragenter", (event) => {
  // 外部拖入时,dataTransfer.types 通常只有 "Files"
  const isExternal = event.dataTransfer.types.length === 1 &&
                     event.dataTransfer.types[0] === "Files"
  console.log("来自外部:", isExternal)
})

Q9:为什么 iOS Safari 上拖拽几乎不可用?

这是 iOS Safari 的已知限制。iOS Safari 对 HTML5 原生 DnD API 的支持极其有限:

  • draggable 属性:在 iOS Safari 上基本无效,元素无法通过原生 DnD 拖动
  • dragstart / dragover / drop 事件:不会正常触发或行为异常
  • 文件拖放:部分版本支持从照片 app 拖入图片,但不稳定

解决方案

  1. 首选方案:使用 Pointer Events API 实现统一的桌面/移动端拖拽(参见第 8.3 节)
  2. 备选方案:引入 @dnd-kit/coreShopify Draggable 等基于 Pointer Events 的库
  3. 最低限度:为 iOS 用户提供完全替代的交互方式(如长按弹出操作菜单、滑动操作等)
javascript
// iOS Safari 检测与降级
const isIOSSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) &&
                   /iPad|iPhone|iPod/.test(navigator.userAgent)

if (isIOSSafari) {
  // 启用 Pointer Events 方案替代原生 DnD
  initPointerBasedDragDrop()
} else {
  // 使用原生 HTML5 DnD
  initNativeDragDrop()
}

Q10:如何解决长按拖拽与文字选中的冲突?

在可拖拽元素上进行长按操作时,浏览器默认会触发文字选中行为,导致拖拽体验不佳。

解决方案组合

css
/* 方案 1:CSS 禁止文字选中 */
.draggable-item {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;  /* iOS:禁止长按菜单 */
}
javascript
// 方案 2:动态控制 user-select(仅在拖拽时禁用)
const draggables = document.querySelectorAll(".draggable-item")

draggables.forEach(item => {
  // mousedown/touchstart 时禁用选中
  item.addEventListener("pointerdown", () => {
    item.style.userSelect = "none"
    item.style.webkitUserSelect = "none"
  })

  // mouseup/touchend 时恢复选中
  document.addEventListener("pointerup", () => {
    draggables.forEach(el => {
      el.style.userSelect = ""
      el.style.webkitUserSelect = ""
    })
  }, { once: false })
})

// 方案 3:区分拖拽和选中的阈值(延迟判断)
let pressTimer = null
const DRAG_THRESHOLD = 150  // 150ms 后才认为是拖拽

item.addEventListener("pointerdown", () => {
  pressTimer = setTimeout(() => {
    // 超过阈值 → 进入拖拽模式
    item.style.userSelect = "none"
    startDrag(item)
  }, DRAG_THRESHOLD)
})

item.addEventListener("pointerup", () => {
  if (pressTimer) {
    clearTimeout(pressTimer)
    pressTimer = null
    // 短按 → 不触发拖拽,允许正常选中行为
  }
})

item.addEventListener("pointermove", () => {
  // 如果手指/鼠标移动了,提前判定为拖拽意图
  if (pressTimer) {
    clearTimeout(pressTimer)
    pressTimer = null
    item.style.userSelect = "none"
    startDrag(item)
  }
})

推荐策略:对于明确的拖拽区域(如拖拽手柄),直接使用 CSS user-select: none;对于内容区域需要在拖拽和选中之间平衡时,使用方案 3 的阈值判定方式。

Q11:为什么自定义拖拽光标(setDragImage)在某些情况下不显示?

setDragImage 设置的自定义拖拽图像不显示是常见的兼容性问题,主要原因如下:

原因现象解决方案
图像元素不在 DOM 中显示默认拖拽图像确保 setDragImage 的参数元素已挂载到 DOM
图像元素被 display:none 隐藏显示默认拖拽图像使用 position: absolute; left: -9999px 替代 display:none
图像尚未渲染完成显示空白或默认图像dragstart 前确保图像已加载(img.complete === true
跨域 iframe 中的元素图像不显示跨域 iframe 中的元素无法作为拖拽图像
Firefox 的特殊限制有时不显示外部图像Firefox 要求图像与页面同源
移动端浏览器大多数不支持setDragImage 在移动端支持不稳定,应作为增强而非依赖

可靠的 setDragImage 使用模式

javascript
// 推荐做法:预创建隐藏的拖拽图像元素
const dragImageTemplate = document.createElement("div")
dragImageTemplate.className = "drag-image-template"
dragImageTemplate.style.cssText = `
  position: absolute;
  top: -9999px;
  left: -9999px;
  width: auto;
  height: auto;
  pointer-events: none;
  z-index: -1;
`
dragImageTemplate.innerHTML = `
  <div style="
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    padding: 10px 20px;
    border-radius: 8px;
    font-weight: bold;
    font-size: 14px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
    white-space: nowrap;
  ">正在拖拽...</div>
`
document.body.appendChild(dragImageTemplate)

// 在 dragstart 中使用
sourceElement.addEventListener("dragstart", (e) => {
  // 克隆模板以确保每次拖拽都是独立的元素
  const image = dragImageTemplate.cloneNode(true)
  image.style.top = "-9999px"
  document.body.appendChild(image)

  e.dataTransfer.setDragImage(image, 50, 20)

  // 清理临时克隆(setDragImage 已捕获快照)
  requestAnimationFrame(() => image.remove())
})

19. 最佳实践检查清单

  • 已覆盖拖拽源、放置目标、状态管理与错误处理
  • 已实现文件拖放与点击选择双入口
  • 已实现大小/类型/数量校验与失败提示
  • 已提供排序结果持久化与回滚机制
  • 已完成无障碍与移动端回退方案
  • 已优化高频事件性能(节流/RAF)
  • 已处理嵌套元素导致的事件冒泡问题
  • 已测试跨浏览器兼容性
  • 已实现服务端安全校验
  • 已提供调试工具与错误日志
  • 已评估并选择合适的拖拽库(非自研时)
  • 已建立性能基线监控(大列表场景)
  • 已实现 Undo/Redo 机制(数据变更类拖拽)
  • 已处理 iOS Safari 等特殊浏览器的降级方案
  • 已解决长按拖拽与文字选中的冲突

补充示例

<h4>001-basic-drag-drop.html</h4>
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>【1】基础数据拖放</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
    .demo-container { max-width: 800px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }

    .drag-area {
      display: flex;
      gap: 40px;
      align-items: flex-start;
      flex-wrap: wrap;
      justify-content: center;
      padding: 30px 20px;
    }

    .drag-source {
      width: 180px;
      height: 120px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border-radius: 12px;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: grab;
      font-size: 16px;
      font-weight: bold;
      user-select: none;
      transition: all 0.3s;
      box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
    }
    .drag-source:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5); }
    .drag-source:active { cursor: grabbing; transform: scale(0.95); }

    .arrow {
      font-size: 32px;
      color: #ccc;
      padding-top: 40px;
    }

    .drop-target {
      width: 240px;
      min-height: 160px;
      border: 3px dashed #ccc;
      border-radius: 12px;
      display: flex;
      align-items: center;
      justify-content: center;
      text-align: center;
      transition: all 0.3s;
      position: relative;
      padding: 20px;
    }

    .drop-target.drag-over {
      border-color: #28a745;
      background: #e8f5e9;
      transform: scale(1.02);
    }

    .drop-placeholder {
      color: #999;
      font-size: 14px;
    }

    .drop-content {
      width: 100%;
    }

    .dropped-card {
      background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
      color: white;
      padding: 16px;
      border-radius: 8px;
      text-align: center;
      animation: slideIn 0.3s ease;
    }

    @keyframes slideIn {
      from { opacity: 0; transform: translateY(-10px); }
      to { opacity: 1; transform: translateY(0); }
    }

    .reset-btn {
      margin-top: 20px;
      padding: 10px 24px;
      background: #6c757d;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
      display: block;
      margin-left: auto;
      margin-right: auto;
    }
    .reset-btn:hover { background: #5a6268; }

    .log-panel {
      margin-top: 20px;
      padding: 16px;
      background: #1e1e1e;
      color: #d4d4d4;
      border-radius: 6px;
      font-family: monospace;
      font-size: 13px;
      max-height: 200px;
      overflow-y: auto;
    }
    .log-entry { margin-bottom: 4px; }
    .log-entry.info { color: #569cd6; }
    .log-entry.success { color: #4ec9b0; }
  </style>
</head>
<body>
  <div class="demo-container">
    <div class="demo-title">示例:基础数据拖放 - DataTransfer 数据传递</div>

    <div class="drag-area">
      <div id="dragCard" draggable="true" class="drag-source">
        📦 拖拽卡片 A<br><small style="font-weight:normal;font-size:12px;">携带 JSON 数据</small>
      </div>

      <div class="arrow">→</div>

      <div id="dropZone" class="drop-target">
        <span class="drop-placeholder">🎯 放置区域<br>将卡片拖到这里</span>
      </div>
    </div>

    <button class="reset-btn" onclick="resetDemo()">重置演示</button>

    <div class="log-panel" id="logPanel">
      <div class="log-entry info">[系统] 等待拖放操作...</div>
    </div>
  </div>

  <script>
    const dragCard = document.getElementById("dragCard")
    const dropZone = document.getElementById("dropZone")
    const logPanel = document.getElementById("logPanel")

    // 拖拽开始
    dragCard.addEventListener("dragstart", (event) => {
      const data = {
        id: "card-a",
        type: "card",
        title: "拖拽卡片 A",
        timestamp: Date.now()
      }

      event.dataTransfer.effectAllowed = "move"
      event.dataTransfer.setData("application/json", JSON.stringify(data))

      log(`[dragstart] 开始拖拽,数据: ${JSON.stringify(data)}`, "info")
    })

    // 拖拽结束
    dragCard.addEventListener("dragend", () => {
      log("[dragend] 拖拽结束", "info")
    })

    // 允许放置
    dropZone.addEventListener("dragover", (event) => {
      event.preventDefault()
      event.dataTransfer.dropEffect = "move"
      dropZone.classList.add("drag-over")
    })

    // 离开放置区
    dropZone.addEventListener("dragleave", (event) => {
      if (!dropZone.contains(event.relatedTarget)) {
        dropZone.classList.remove("drag-over")
      }
    })

    // 放置处理
    dropZone.addEventListener("drop", (event) => {
      event.preventDefault()
      dropZone.classList.remove("drag-over")

      try {
        const raw = event.dataTransfer.getData("application/json")
        if (!raw) {
          log("[drop] 未获取到数据", "error")
          return
        }

        const payload = JSON.parse(raw)

        dropZone.innerHTML = `
          <div class="drop-content">
            <div class="dropped-card">
              <strong style="font-size:18px;">✓ 放置成功</strong><br><br>
              <strong>ID:</strong> ${payload.id}<br>
              <strong>类型:</strong> ${payload.type}<br>
              <strong>标题:</strong> ${payload.title}<br>
              <strong>时间戳:</strong> ${new Date(payload.timestamp).toLocaleTimeString()}
            </div>
          </div>
        `

        log(`[drop] 成功接收数据: ID=${payload.id}, Type=${payload.type}`, "success")
      } catch (error) {
        log(`[drop] 解析数据失败: ${error.message}`, "error")
      }
    })

    function resetDemo() {
      dropZone.innerHTML = '<span class="drop-placeholder">🎯 放置区域<br>将卡片拖到这里</span>'
      dropZone.classList.remove("drag-over")
      log("[系统] 已重置", "info")
    }

    function log(message, type = "info") {
      const entry = document.createElement("div")
      entry.className = `log-entry ${type}`
      entry.textContent = message
      logPanel.appendChild(entry)
      logPanel.scrollTop = logPanel.scrollHeight
    }
  </script>
</body>
</html>```
<h4>002-sortable-list.html</h4>

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>【2】可排序列表</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
    .demo-container { max-width: 600px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }

    .sortable-list {
      list-style: none;
      padding: 0;
    }

    .sortable-item {
      padding: 14px 18px;
      margin: 8px 0;
      background: white;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
      cursor: grab;
      display: flex;
      align-items: center;
      gap: 12px;
      transition: all 0.2s;
      user-select: none;
    }

    .sortable-item:hover {
      border-color: #007bff;
      box-shadow: 0 2px 8px rgba(0,123,255,0.15);
    }

    .sortable-item.dragging {
      opacity: 0.5;
      border-color: #007bff;
      background: #e7f3ff;
    }

    .sortable-item .handle {
      color: #999;
      font-size: 18px;
      cursor: grab;
    }

    .sortable-item .content {
      flex: 1;
      font-weight: 500;
    }

    .sortable-item .badge {
      background: #e9ecef;
      color: #666;
      padding: 4px 10px;
      border-radius: 12px;
      font-size: 12px;
    }

    .order-display {
      margin-top: 20px;
      padding: 16px;
      background: #f8f9fa;
      border-radius: 6px;
      font-size: 13px;
    }

    .order-label {
      font-weight: 600;
      color: #555;
      margin-bottom: 8px;
    }

    .order-sequence {
      color: #007bff;
      font-family: monospace;
      word-break: break-all;
    }

    .hint {
      margin-top: 12px;
      font-size: 13px;
      color: #999;
      text-align: center;
    }
  </style>
</head>
<body>
  <div class="demo-container">
    <div class="demo-title">示例:可排序列表 - 拖拽排序</div>

    <ul id="sortable" class="sortable-list">
      <li draggable="true" data-id="1" class="sortable-item">
        <span class="handle">☰</span>
        <span class="content">学习 HTML5 基础知识</span>
        <span class="badge">优先级: 高</span>
      </li>
      <li draggable="true" data-id="2" class="sortable-item">
        <span class="handle">☰</span>
        <span class="content">掌握 Canvas 绑图 API</span>
        <span class="badge">优先级: 中</span>
      </li>
      <li draggable="true" data-id="3" class="sortable-item">
        <span class="handle">☰</span>
        <span class="content">实践 SVG 矢量图形</span>
        <span class="badge">优先级: 中</span>
      </li>
      <li draggable="true" data-id="4" class="sortable-item">
        <span class="handle">☰</span>
        <span class="content">了解 Web Workers 多线程</span>
        <span class="badge">优先级: 低</span>
      </li>
      <li draggable="true" data-id="5" class="sortable-item">
        <span class="handle">☰</span>
        <span class="content">学习地理位置定位 API</span>
        <span class="badge">优先级: 低</span>
      </li>
    </ul>

    <p class="hint">💡 提示:拖拽列表项可以重新排序</p>

    <div class="order-display">
      <div class="order-label">当前顺序:</div>
      <div class="order-sequence" id="orderSequence"></div>
    </div>
  </div>

  <script>
    const list = document.getElementById("sortable")
    let dragging = null

    function updateOrderDisplay() {
      const items = list.querySelectorAll(".sortable-item")
      const ids = Array.from(items).map(item => item.dataset.id)
      document.getElementById("orderSequence").textContent = ids.join(" → ")
    }

    updateOrderDisplay()

    list.addEventListener("dragstart", (event) => {
      const target = event.target.closest(".sortable-item")
      if (!target) return

      dragging = target
      dragging.classList.add("dragging")

      event.dataTransfer.effectAllowed = "move"
      event.dataTransfer.setData("text/plain", target.dataset.id)
    })

    list.addEventListener("dragend", () => {
      if (dragging) {
        dragging.classList.remove("dragging")
        dragging = null
      }
      updateOrderDisplay()
    })

    list.addEventListener("dragover", (event) => {
      event.preventDefault()

      const target = event.target.closest(".sortable-item")
      if (!target || target === dragging) return

      const rect = target.getBoundingClientRect()
      const isAfter = event.clientY > rect.top + rect.height / 2

      if (isAfter) {
        list.insertBefore(dragging, target.nextSibling)
      } else {
        list.insertBefore(dragging, target)
      }
    })

    list.addEventListener("drop", (event) => {
      event.preventDefault()
      updateOrderDisplay()
    })
  </script>
</body>
</html>```
<h4>003-kanban-board.html</h4>

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>【3】跨容器拖放(看板)</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
    .demo-container { max-width: 1000px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .demo-title { margin-bottom: 20px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }

    .board {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 20px;
    }

    .column {
      background: #f8f9fa;
      border-radius: 10px;
      padding: 16px;
      min-height: 350px;
      transition: all 0.3s;
    }

    .column.drag-over {
      background: #e7f3ff;
      box-shadow: inset 0 0 0 3px #007bff;
    }

    .column-header {
      font-weight: 700;
      font-size: 15px;
      margin-bottom: 12px;
      padding-bottom: 10px;
      border-bottom: 2px solid #e0e0e0;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .column-header.todo { color: #6c757d; border-color: #6c757d; }
    .column-header.doing { color: #fd7e14; border-color: #fd7e14; }
    .column-header.done { color: #28a745; border-color: #28a745; }

    .count-badge {
      background: #e9ecef;
      padding: 2px 8px;
      border-radius: 10px;
      font-size: 12px;
      color: #666;
    }

    .task-card {
      background: white;
      padding: 14px;
      margin-bottom: 10px;
      border-radius: 8px;
      border-left: 4px solid #007bff;
      cursor: move;
      box-shadow: 0 1px 3px rgba(0,0,0,0.08);
      transition: all 0.2s;
      user-select: none;
    }

    .task-card:hover {
      box-shadow: 0 4px 12px rgba(0,0,0,0.12);
      transform: translateY(-2px);
    }

    .task-card.dragging {
      opacity: 0.4;
      transform: rotate(3deg);
    }

    .task-title {
      font-weight: 500;
      font-size: 14px;
      margin-bottom: 6px;
      line-height: 1.4;
    }

    .task-meta {
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-size: 12px;
      color: #888;
    }

    .task-tag {
      background: #e3f2fd;
      color: #1976d2;
      padding: 2px 8px;
      border-radius: 4px;
      font-size: 11px;
    }

    .log-bar {
      margin-top: 20px;
      padding: 12px 16px;
      background: #1e1e1e;
      color: #4ec9b0;
      border-radius: 6px;
      font-family: monospace;
      font-size: 13px;
      min-height: 44px;
    }
  </style>
</head>
<body>
  <div class="demo-container">
    <div class="demo-title">示例:跨容器拖放 - 看板(Kanban)</div>

    <div class="board">
      <div class="container column" data-status="todo">
        <div class="column-header todo">
          📋 待办
          <span class="count-badge" id="todoCount">0</span>
        </div>
        <div class="task-card" draggable="true" data-id="1">
          <div class="task-title">学习 HTML5 语义化标签</div>
          <div class="task-meta"><span class="task-tag">基础</span><span>#1</span></div>
        </div>
        <div class="task-card" draggable="true" data-id="2">
          <div class="task-title">掌握 Canvas 基础绑图</div>
          <div class="task-meta"><span class="task-tag">进阶</span><span>#2</span></div>
        </div>
        <div class="task-card" draggable="true" data-id="3">
          <div class="task-title">了解 Web Storage API</div>
          <div class="task-meta"><span class="task-tag">基础</span><span>#3</span></div>
        </div>
      </div>

      <div class="container column" data-status="doing">
        <div class="column-header doing">
          🔨 进行中
          <span class="count-badge" id="doingCount">0</span>
        </div>
        <div class="task-card" draggable="true" data-id="4">
          <div class="task-title">实践 SVG 动画效果</div>
          <div class="task-meta"><span class="task-tag">进阶</span><span>#4</span></div>
        </div>
      </div>

      <div class="container column" data-status="done">
        <div class="column-header done">
          ✅ 已完成
          <span class="count-badge" id="doneCount">0</span>
        </div>
        <div class="task-card" draggable="true" data-id="5">
          <div class="task-title">搭建开发环境</div>
          <div class="task-meta"><span class="task-tag">环境</span><span>#5</span></div>
        </div>
      </div>
    </div>

    <div class="log-bar" id="logBar">💡 拖拽任务卡片到不同列来改变状态...</div>
  </div>

  <script>
    const containers = document.querySelectorAll(".container")
    const logBar = document.getElementById("logBar")
    let dragging = null
    let sourceContainer = null

    function updateCounts() {
      containers.forEach(container => {
        const status = container.dataset.status
        const count = container.querySelectorAll(".task-card").length
        const badge = document.getElementById(`${status}Count`)
        if (badge) badge.textContent = count
      })
    }

    updateCounts()

    // 全局拖拽开始
    document.addEventListener("dragstart", (event) => {
      const item = event.target.closest(".task-card")
      if (!item) return

      dragging = item
      sourceContainer = item.closest(".container")
      item.classList.add("dragging")

      event.dataTransfer.effectAllowed = "move"
      event.dataTransfer.setData("text/plain", item.dataset.id)
    })

    // 全局拖拽结束
    document.addEventListener("dragend", () => {
      if (dragging) {
        dragging.classList.remove("dragging")
        dragging = null
      }
      sourceContainer = null
      containers.forEach(c => c.classList.remove("drag-over"))
      updateCounts()
    })

    // 容器事件绑定
    containers.forEach(container => {
      container.addEventListener("dragenter", (event) => {
        event.preventDefault()
        container.classList.add("drag-over")
      })

      container.addEventListener("dragover", (event) => {
        event.preventDefault()
        event.dataTransfer.dropEffect = "move"
      })

      container.addEventListener("dragleave", (event) => {
        if (!container.contains(event.relatedTarget)) {
          container.classList.remove("drag-over")
        }
      })

      container.addEventListener("drop", (event) => {
        event.preventDefault()
        container.classList.remove("drag-over")

        if (!dragging) return

        const targetItem = event.target.closest(".task-card")
        if (targetItem && targetItem !== dragging) {
          const rect = targetItem.getBoundingClientRect()
          const isAfter = event.clientY > rect.top + rect.height / 2
          container.insertBefore(dragging, isAfter ? targetItem.nextSibling : targetItem)
        } else {
          container.appendChild(dragging)
        }

        // 记录状态变化
        const newStatus = container.dataset.status
        const oldStatus = sourceContainer?.dataset.status

        if (newStatus !== oldStatus && oldStatus) {
          const statusNames = { todo: "待办", doing: "进行中", done: "已完成" }
          const taskTitle = dragging.querySelector(".task-title").textContent.trim()

          logBar.textContent = `✅ 任务「${taskTitle}」从「${statusNames[oldStatus]}」移动到「${statusNames[newStatus]}」`

          setTimeout(() => {
            logBar.textContent = "💡 拖拽任务卡片到不同列来改变状态..."
          }, 3000)
        }
      })
    })
  </script>
</body>
</html>```
<h4>004-file-drop-validate.html</h4>

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>【4】文件拖放 + 校验</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
    .demo-container { max-width: 700px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }

    .dropzone {
      border: 3px dashed #007bff;
      border-radius: 12px;
      padding: 50px;
      text-align: center;
      cursor: pointer;
      transition: all 0.3s;
      background: #f8f9ff;
      margin-bottom: 20px;
    }

    .dropzone:hover { background: #e7f3ff; border-color: #0056b3; }
    .dropzone.drag-over {
      background: #d4edda;
      border-color: #28a745;
      transform: scale(1.02);
    }

    .dropzone .icon { font-size: 56px; margin-bottom: 16px; }
    .dropzone p { margin: 6px 0; font-size: 15px; color: #555; }
    .dropzone .hint { font-size: 13px; color: #888; }

    input[type="file"] { display: none; }

    .file-list { list-style: none; }
    .file-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 14px 18px;
      margin-bottom: 10px;
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      transition: all 0.2s;
    }
    .file-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }

    .file-info { flex: 1; }
    .file-name { font-weight: 500; font-size: 14px; }
    .file-meta { font-size: 12px; color: #666; margin-top: 4px; }

    .file-status {
      padding: 4px 12px;
      border-radius: 12px;
      font-size: 12px;
      font-weight: 500;
    }
    .status-valid { background: #d4edda; color: #155724; }
    .status-invalid { background: #f8d7da; color: #721c24; }

    .config-bar {
      display: flex;
      gap: 12px;
      align-items: center;
      margin-bottom: 16px;
      flex-wrap: wrap;
      font-size: 14px;
    }
    .config-bar label { font-weight: 500; color: #555; }
    .config-bar select { padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; }
  </style>
</head>
<body>
  <div class="demo-container">
    <div class="demo-title">示例:文件拖放上传 + 类型校验</div>

    <div class="config-bar">
      <label>允许的文件类型:</label>
      <select id="allowedTypes" onchange="updateAllowedTypes()">
        <option value="image">仅图片 (jpg/png/gif)</option>
        <option value="document">文档 (pdf/txt/doc)</option>
        <option value="all">所有类型</option>
      </select>
    </div>

    <div class="dropzone" id="dropZone" onclick="document.getElementById('fileInput').click()">
      <div class="icon">📁</div>
      <p><strong>拖放文件到此处</strong></p>
      <p class="hint">或点击选择文件 · 支持多选</p>
      <input type="file" id="fileInput" multiple />
    </div>

    <ul class="file-list" id="fileList"></ul>
  </div>

  <script>
    const dropZone = document.getElementById("dropZone")
    const fileInput = document.getElementById("fileInput")
    const fileList = document.getElementById("fileList")

    let ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
    const MAX_SIZE = 5 * 1024 * 1024 // 5MB

    function updateAllowedTypes() {
      const value = document.getElementById("allowedTypes").value
      switch(value) {
        case "image":
          ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
          break
        case "document":
          ALLOWED_TYPES = ["application/pdf", "text/plain", "application/msword"]
          break
        default:
          ALLOWED_TYPES = []
      }
    }

    // 拖拽事件
    ;["dragenter", "dragover"].forEach(event => {
      dropZone.addEventListener(event, (e) => {
        e.preventDefault()
        dropZone.classList.add("drag-over")
      })
    })

    ;["dragleave", "drop"].forEach(event => {
      dropZone.addEventListener(event, (e) => {
        e.preventDefault()
        dropZone.classList.remove("drag-over")
      })
    })

    dropZone.addEventListener("drop", (event) => {
      handleFiles(Array.from(event.dataTransfer.files))
    })

    fileInput.addEventListener("change", (event) => {
      handleFiles(Array.from(event.target.files || []))
    })

    function handleFiles(files) {
      fileList.innerHTML = ""

      if (files.length === 0) return

      files.forEach(file => {
        const errors = validateFile(file)
        const item = document.createElement("li")
        item.className = "file-item"

        const statusClass = errors.length === 0 ? "status-valid" : "status-invalid"
        const statusText = errors.length === 0 ? "✓ 校验通过" : `✗ ${errors[0]}`

        item.innerHTML = `
          <div class="file-info">
            <div class="file-name">${file.name}</div>
            <div class="file-meta">${formatBytes(file.size)} · ${file.type || "未知类型"}</div>
          </div>
          <span class="file-status ${statusClass}">${statusText}</span>
        `
        fileList.appendChild(item)
      })
    }

    function validateFile(file) {
      const errors = []

      if (!ALLOWED_TYPES.length && file.type) {
        // 允许所有类型时不校验 MIME
      } else if (ALLOWED_TYPES.length > 0 && !ALLOWED_TYPES.includes(file.type)) {
        errors.push("类型不支持")
      }

      if (file.size > MAX_SIZE) {
        errors.push("超过 5MB 限制")
      }

      return errors
    }

    function formatBytes(bytes) {
      if (bytes === 0) return "0 B"
      const k = 1024
      const sizes = ["B", "KB", "MB", "GB"]
      const i = Math.floor(Math.log(bytes) / Math.log(k))
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
    }
  </script>
</body>
</html>```
<h4>006-drag-handle.html</h4>

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>【6】拖拽手柄(Handle)</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
    .demo-container { max-width: 550px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
    .demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }

    .hint-text {
      background: #fff3cd;
      border-left: 4px solid #ffc107;
      padding: 12px 16px;
      margin-bottom: 20px;
      font-size: 14px;
      border-radius: 4px;
    }

    .list { list-style: none; }

    .item {
      display: flex;
      align-items: center;
      padding: 14px 16px;
      margin: 8px 0;
      background: white;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
      transition: all 0.2s;
    }

    .item.dragging {
      opacity: 0.4;
      border-color: #007bff;
      background: #e7f3ff;
    }

    .handle {
      cursor: grab;
      padding: 8px 14px;
      color: #999;
      font-size: 20px;
      user-select: none;
      margin-right: 12px;
      transition: color 0.2s;
    }
    .handle:hover { color: #007bff; }
    .handle:active { cursor: grabbing; }

    .content {
      flex: 1;
      font-weight: 500;
    }

    .badge {
      background: #e9ecef;
      color: #666;
      padding: 4px 10px;
      border-radius: 12px;
      font-size: 12px;
    }

    .order-display {
      margin-top: 20px;
      padding: 14px;
      background: #f8f9fa;
      border-radius: 6px;
      text-align: center;
    }
    .order-display span { color: #007bff; font-weight: 600; }
  </style>
</head>
<body>
  <div class="demo-container">
    <div class="demo-title">示例:拖拽手柄 - 仅通过手柄区域触发拖拽</div>

    <div class="hint-text">
      💡 提示:只有点击左侧的 ☰ 手柄图标才能拖拽排序,点击其他位置无效
    </div>

    <ul class="list" id="sortable">
      <li class="item" draggable="true" data-id="1">
        <span class="handle">☰</span>
        <span class="content">项目 Alpha</span>
        <span class="badge">重要</span>
      </li>
      <li class="item" draggable="true" data-id="2">
        <span class="handle">☰</span>
        <span class="content">项目 Beta</span>
        <span class="badge">普通</span>
      </li>
      <li class="item" draggable="true" data-id="3">
        <span class="handle">☰</span>
        <span class="content">项目 Gamma</span>
        <span class="badge">紧急</span>
      </li>
      <li class="item" draggable="true" data-id="4">
        <span class="handle">☰</span>
        <span class="content">项目 Delta</span>
        <span class="badge">普通</span>
      </li>
      <li class="item" draggable="true" data-id="5">
        <span class="handle">☰</span>
        <span class="content">项目 Epsilon</span>
        <span class="badge">低优先</span>
      </li>
    </ul>

    <div class="order-display">
      当前顺序:<span id="orderDisplay"></span>
    </div>
  </div>

  <script>
    const list = document.getElementById("sortable")
    let dragging = null
    let validDrag = false

    function updateOrder() {
      const items = list.querySelectorAll(".item")
      const names = Array.from(items).map(item =>
        item.querySelector(".content").textContent.trim()
      )
      document.getElementById("orderDisplay").textContent = names.join(" → ")
    }

    updateOrder()

    list.addEventListener("dragstart", (event) => {
      const handle = event.target.closest(".handle")

      // 只允许通过手柄拖拽
      if (!handle) {
        event.preventDefault()
        return
      }

      validDrag = true
      dragging = event.target.closest(".item")
      dragging.classList.add("dragging")

      event.dataTransfer.effectAllowed = "move"
      event.dataTransfer.setData("text/plain", dragging.dataset.id)
    })

    list.addEventListener("dragend", () => {
      if (dragging) dragging.classList.remove("dragging")
      dragging = null
      validDrag = false
      updateOrder()
    })

    list.addEventListener("dragover", (event) => {
      event.preventDefault()

      if (!validDrag || !dragging) return

      const target = event.target.closest(".item")
      if (!target || target === dragging) return

      const rect = target.getBoundingClientRect()
      const isAfter = event.clientY > rect.top + rect.height / 2

      if (isAfter) {
        list.insertBefore(dragging, target.nextSibling)
      } else {
        list.insertBefore(dragging, target)
      }
    })
  </script>
</body>
</html>```
<h4>008-drag-sort-upload-kanban.html</h4>

```html
<!DOCTYPE html>
<!--
  来源:基础知识/11-拖放操作.md
  演示:拖放排序列表、拖拽上传带预览、跨区域拖拽
-->
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>高级拖放:排序、上传、跨区域</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, sans-serif; padding: 30px; background: #f0f2f5; max-width: 960px; margin: 0 auto; }
    h1 { color: #1a1a1a; margin-bottom: 25px; }
    .section { background: white; padding: 25px; margin-bottom: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
    h2 { color: #333; font-size: 17px; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 8px; }

    /* 排序列表 */
    .sortable-list { list-style: none; padding: 0; max-width: 450px; }
    .sortable-item {
      display: flex; align-items: center; gap: 12px;
      padding: 14px 16px; margin: 6px 0; background: #f8f9fa;
      border: 2px solid #e8e8e8; border-radius: 10px;
      cursor: grab; transition: all 0.2s; user-select: none;
    }
    .sortable-item:active { cursor: grabbing; }
    .sortable-item.dragging {
      opacity: 0.4; transform: scale(0.98);
      border-color: #3498db; background: #ebf5fb;
    }
    .sortable-item.drag-over {
      border-color: #27ae60; background: #eafaf1;
      transform: translateY(2px);
    }
    .sort-handle { 
      font-size: 18px; color: #bbb; cursor: grab; padding: 4px;
      user-select: none;
    }
    .sort-handle:hover { color: #666; }
    .sort-info { flex: 1; }
    .sort-info .name { font-weight: 600; font-size: 14px; color: #333; }
    .sort-info .desc { font-size: 12px; color: #888; margin-top: 2px; }
    .sort-rank {
      width: 28px; height: 28px; background: #e9ecef; border-radius: 50%;
      display: flex; align-items: center; justify-content: center;
      font-size: 12px; font-weight: bold; color: #666; flex-shrink: 0;
    }

    /* 文件上传 */
    .upload-zone {
      border: 3px dashed #bdc3c7; border-radius: 14px; padding: 30px;
      text-align: center; transition: all 0.25s; min-height: 160px;
      display: flex; flex-direction: column; align-items: center; justify-content: center;
    }
    .upload-zone.drag-over {
      border-color: #3498db; background: #ebf5fb;
      box-shadow: inset 0 0 30px rgba(52,152,219,0.08);
      transform: scale(1.01);
    }
    .upload-icon { font-size: 48px; margin-bottom: 10px; }
    .upload-text { font-size: 15px; color: #666; }
    .upload-hint { font-size: 12px; color: #aaa; margin-top: 6px; }

    .file-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; margin-top: 15px; }
    .file-item {
      background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 10px;
      overflow: hidden; position: relative;
    }
    .file-item img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
    .file-item .file-name {
      padding: 6px 8px; font-size: 11px; color: #555; white-space: nowrap;
      overflow: hidden; text-overflow: ellipsis;
    }
    .file-item .remove-btn {
      position: absolute; top: 4px; right: 4px; width: 22px; height: 22px;
      background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
      cursor: pointer; font-size: 12px; display: flex; align-items: center;
      justify-content: center; opacity: 0; transition: opacity 0.2s;
    }
    .file-item:hover .remove-btn { opacity: 1; }

    /* 跨区域拖拽(看板) */
    .kanban-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; }
    @media (max-width: 700px) { .kanban-board { grid-template-columns: 1fr; } }
    .kanban-column {
      background: #f8f9fa; border-radius: 12px; padding: 14px;
      border: 2px solid transparent; transition: all 0.2s;
      min-height: 200px;
    }
    .kanban-column.drag-over { border-color: #3498db; background: #ebf5fb; }
    .kanban-header {
      display: flex; align-items: center; justify-content: space-between;
      margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #eee;
    }
    .kanban-header h4 { font-size: 14px; display: flex; align-items: center; gap: 6px; }
    .kanban-count {
      background: #e0e0e0; padding: 2px 8px; border-radius: 10px;
      font-size: 11px; color: #666;
    }
    .kanban-card {
      background: white; padding: 12px 14px; margin-bottom: 8px;
      border-radius: 8px; border-left: 3px solid #ddd;
      cursor: grab; font-size: 13px; transition: all 0.15s;
      box-shadow: 0 1px 3px rgba(0,0,0,0.08);
    }
    .kanban-card:active { cursor: grabbing; }
    .kanban-card.dragging { opacity: 0.35; transform: rotate(3deg); box-shadow: 0 8px 20px rgba(0,0,0,0.15); }
    .kanban-card .tag {
      display: inline-block; padding: 2px 8px; border-radius: 4px;
      font-size: 10px; margin-top: 6px;
    }
    .tag-high { background: #fee2e2; color: #dc2626; }
    .tag-mid { background: #fef3c7; color: #d97706; }
    .tag-low { background: #d1fae5; color: #059669; }

    pre { background: #263238; color: #eceff1; padding: 14px; border-radius: 8px; overflow-x: auto; font-size: 12px; line-height: 1.6; }
  </style>
</head>
<body>
  <h1>🎯 高级拖放实战演示</h1>

  <!-- ========== 拖拽排序列表 ========== -->
  <div class="section">
    <h2>1. 拖拽排序列表</h2>
    <p style="font-size:13px;color:#666;margin-bottom:12px;">拖动列表项调整顺序:</p>

    <ul class="sortable-list" id="sortable-list">
      <li class="sortable-item" draggable="true">
        <span class="sort-handle">⠿</span>
        <span class="sort-rank">1</span>
        <div class="sort-info">
          <div class="name">学习 HTML 基础语法</div>
          <div class="desc">了解标签、属性、文档结构</div>
        </div>
      </li>
      <li class="sortable-item" draggable="true">
        <span class="sort-handle">⠿</span>
        <span class="sort-rank">2</span>
        <div class="sort-info">
          <div class="name">掌握 CSS 选择器和布局</div>
          <div class="desc">Flexbox、Grid、响应式设计</div>
        </div>
      </li>
      <li class="sortable-item" draggable="true">
        <span class="sort-handle">⠿</span>
        <span class="sort-rank">3</span>
        <div class="sort-info">
          <div class="name">JavaScript 核心概念</div>
          <div class="desc">变量、函数、DOM 操作、事件</div>
        </div>
      </li>
      <li class="sortable-item" draggable="true">
        <span class="sort-handle">⠿</span>
        <span class="sort-rank">4</span>
        <div class="sort-info">
          <div class="name">前端框架入门</div>
          <div class="desc">React / Vue.js 基础使用</div>
        </div>
      </li>
      <li class="sortable-item" draggable="true">
        <span class="sort-handle">⠿</span>
        <span class="sort-rank">5</span>
        <div class="sort-info">
          <div class="name">构建工具与工程化</div>
          <div class="desc">Webpack、Vite、ESLint、Git</div>
        </div>
      </li>
    </ul>

    <pre id="sort-log" style="margin-top:15px;font-size:11px;">// 拖拽排序日志...</pre>
  </div>

  <!-- ========== 文件拖拽上传 ========== -->
  <div class="section">
    <h2>2. 文件拖拽上传(带图片预览)</h2>

    <div class="upload-zone" id="upload-zone">
      <div class="upload-icon">📁</div>
      <div class="upload-text">将文件拖到此处,或点击选择</div>
      <div class="upload-hint">支持 JPG、PNG、GIF 格式,可多选</div>
      <input type="file" id="file-input" multiple accept="image/*" style="display:none;">
    </div>

    <div class="file-preview-grid" id="preview-grid"></div>
  </div>

  <!-- ========== 看板式跨区域拖拽 ========== -->
  <div class="section">
    <h2>3. 看板式跨区域拖拽</h2>
    <p style="font-size:13px;color:#666;margin-bottom:12px;">在列之间拖拽卡片:</p>

    <div class="kanban-board">
      <div class="kanban-column" data-status="todo" ondragover="kanbanDragOver(event)" ondrop="kanbanDrop(event)" ondragleave="kanbanDragLeave(event)">
        <div class="kanban-header">
          <h4>📋 待办 <span class="kanban-count" id="count-todo">3</span></h4>
        </div>
        <div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
          设计登录页面 UI 原型
          <span class="tag tag-high">高优先级</span>
        </div>
        <div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
          编写用户认证 API 接口
          <span class="tag tag-high">高优先级</span>
        </div>
        <div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
          配置开发环境
          <span class="tag tag-mid">中优先级</span>
        </div>
      </div>

      <div class="kanban-column" data-status="doing" ondragover="kanbanDragOver(event)" ondrop="kanbanDrop(event)" ondragleave="kanbanDragLeave(event)">
        <div class="kanban-header">
          <h4>🔨 进行中 <span class="kanban-count" id="count-doing">2</span></h4>
        </div>
        <div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
          实现首页布局组件
          <span class="tag tag-mid">中优先级</span>
        </div>
        <div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
          对接第三方支付 SDK
          <span class="tag tag-high">高优先级</span>
        </div>
      </div>

      <div class="kanban-column" data-status="done" ondragover="kanbanDragOver(event)" ondrop="kanbanDrop(event)" ondragleave="kanbanDragLeave(event)">
        <div class="kanban-header">
          <h4>✅ 已完成 <span class="kanban-count" id="count-done">1</span></h4>
        </div>
        <div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
          项目初始化与脚手架搭建
          <span class="tag tag-low">低优先级</span>
        </div>
      </div>
    </div>
  </div>

  <script>
    // ===== 1. 排序列表 =====
    const sortList = document.getElementById('sortable-list');
    const sortLog = document.getElementById('sort-log');
    let draggedSortItem = null;

    sortList.querySelectorAll('.sortable-item').forEach(item => {
      item.addEventListener('dragstart', (e) => {
        draggedSortItem = item;
        item.classList.add('dragging');
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/plain', 'sort');
        logSort(`[dragstart] 开始拖拽: "${item.querySelector('.name').textContent}"`);
      });

      item.addEventListener('dragend', () => {
        item.classList.remove('dragging');
        draggedSortItem = null;
        sortList.querySelectorAll('.sortable-item').forEach(i => i.classList.remove('drag-over'));
        updateSortRanks();
        logSort('[dragend] 拖拽结束,已更新排名');
      });

      item.addEventListener('dragover', (e) => {
        e.preventDefault();
        if (!draggedSortItem || draggedSortItem === item) return;
        
        const rect = item.getBoundingClientRect();
        const midY = rect.top + rect.height / 2;
        
        if (e.clientY < midY) {
          item.parentNode.insertBefore(draggedSortItem, item);
        } else {
          item.parentNode.insertBefore(draggedSortId, item.nextSibling);
        }
        item.classList.add('drag-over');
      });

      item.addEventListener('dragleave', () => {
        item.classList.remove('drag-over');
      });
    });

    function updateSortRanks() {
      sortList.querySelectorAll('.sortable-item').forEach((item, i) => {
        item.querySelector('.sort-rank').textContent = i + 1;
      });
    }

    function logSort(msg) {
      sortLog.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + sortLog.textContent;
    }

    // ===== 2. 文件上传 =====
    const uploadZone = document.getElementById('upload-zone');
    const fileInput = document.getElementById('file-input');
    const previewGrid = document.getElementById('preview-grid');

    uploadZone.addEventListener('click', () => fileInput.click());

    uploadZone.addEventListener('dragover', (e) => {
      e.preventDefault();
      uploadZone.classList.add('drag-over');
    });

    uploadZone.addEventListener('dragleave', () => {
      uploadZone.classList.remove('drag-over');
    });

    uploadZone.addEventListener('drop', (e) => {
      e.preventDefault();
      uploadZone.classList.remove('drag-over');
      handleFiles(e.dataTransfer.files);
    });

    fileInput.addEventListener('change', () => {
      handleFiles(fileInput.files);
    });

    function handleFiles(files) {
      Array.from(files).forEach(file => {
        if (!file.type.startsWith('image/')) return;

        const reader = new FileReader();
        reader.onload = (e) => {
          const item = document.createElement('div');
          item.className = 'file-item';
          item.innerHTML = `
            <img src="${e.target.result}" alt="${file.name}">
            <button class="remove-btn" onclick="this.parentElement.remove()">✕</button>
            <div class="file-name">${file.name}</div>`;
          previewGrid.appendChild(item);
        };
        reader.readAsDataURL(file);
      });
    }

    // ===== 3. 看板拖拽 =====
    let draggedCard = null;

    function kanbanDragStart(e) {
      draggedCard = e.target;
      e.target.classList.add('dragging');
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', 'kanban');
    }

    function kanbanDragOver(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      e.currentTarget.classList.add('drag-over');
    }

    function kanbanDragLeave(e) {
      e.currentTarget.classList.remove('drag-over');
    }

    function kanbanDrop(e) {
      e.preventDefault();
      e.currentTarget.classList.remove('drag-over');

      if (draggedCard) {
        const body = e.currentTarget.querySelector('.kanban-header')?.nextElementSibling || e.currentTarget;
        body.appendChild(draggedCard);
        draggedCard.classList.remove('dragging');
        updateKanbanCounts();
      }
    }

    // 全局 dragend 处理
    document.addEventListener('dragend', (e) => {
      if (e.target.classList.contains('kanban-card')) {
        e.target.classList.remove('dragging');
        document.querySelectorAll('.kanban-column').forEach(col => col.classList.remove('drag-over'));
        updateKanbanCounts();
      }
    });

    function updateKanbanCounts() {
      ['todo', 'doing', 'done'].forEach(status => {
        const col = document.querySelector(`[data-status="${status}"]`);
        const count = col.querySelectorAll('.kanban-card').length;
        document.getElementById(`count-${status}`).textContent = count;
      });
    }
  </script>
</body>
</html>
<h4>009-touch-drag-state-machine.html</h4>
html
<!DOCTYPE html>
<!--
  来源:基础知识/11-拖放操作.md
  演示:触摸设备拖拽(Touch Events + CSS touch-action)、拖拽状态机
-->
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>触摸拖拽与状态机演示</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, sans-serif; padding: 30px; background: #f0f2f5; max-width: 900px; margin: 0 auto; min-height: 100vh; }
    h1 { color: #1a1a1a; margin-bottom: 25px; }
    .section { background: white; padding: 25px; margin-bottom: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
    h2 { color: #333; font-size: 17px; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 8px; }

    /* 触摸拖拽画布 */
    .touch-canvas {
      position: relative; width: 100%; height: 350px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      border-radius: 12px; overflow: hidden; touch-action: none;
      user-select: none;
    }
    .touch-canvas .grid-bg {
      position: absolute; inset: 0;
      background-image:
        linear-gradient(rgba(255,255,255,0.05) 1px 1px, transparent 1px,
        linear-gradient(90deg, rgba(255,255,255,0.05) 1px 1px, transparent 1px);
      background-size: 40px 40px;
    }
    .draggable-chip {
      position: absolute; padding: 12px 20px; border-radius: 25px;
      color: white; font-size: 14px; font-weight: 600;
      cursor: grab; user-select: none; touch-action: none;
      display: flex; align-items: center; gap: 8px;
      box-shadow: 0 4px 15px rgba(0,0,0,0.3);
      transition: box-shadow 0.15s;
      z-index: 1;
    }
    .draggable-chip:active, .draggable-chip.dragging {
      cursor: grabbing; z-index: 100;
      box-shadow: 0 12px 35px rgba(0,0,0,0.5);
      transform: scale(1.05);
    }

    /* 状态机面板 */
    .state-machine {
      display: flex; justify-content: center; gap: 8px; flex-wrap: wrap;
      margin: 20px 0; padding: 16px; background: #f8f9fa; border-radius: 10px;
    }
    .state-node {
      padding: 10px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
      border: 2px solid #e0e0e0; background: white; transition: all 0.3s;
      position: relative;
    }
    .state-node.active {
      border-color: #3498db; background: #ebf5fb; color: #2980b9;
      box-shadow: 0 0 0 3px rgba(52,152,219,0.2), 0 4px 12px rgba(52,152,219,0.15);
      transform: scale(1.05);
    }
    .state-arrow {
      font-size: 18px; color: #bbb; align-self: center;
    }

    /* touch-action 演示区 */
    .ta-demo {
      display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 15px 0;
    }
    @media (max-width: 650px) { .ta-demo { grid-template-columns: 1fr 1fr; } }
    @media (max-width: 400px) { .ta-demo { grid-template-columns: 1fr; } }
    .ta-box {
      padding: 30px 16px; border-radius: 10px; text-align: center;
      font-size: 13px; color: #555; font-weight: 500; cursor: grab;
      touch-action: pan-y; user-select: none;
      border: 2px solid #e0e0e0; transition: all 0.2s;
      display: flex; flex-direction: column; align-items: center; gap: 8px;
    }
    .ta-box:active { background: #ebf5fb; border-color: #3498db; }
    .ta-box .swipe-icon { font-size: 28px; }
    .ta-box .action-label {
      font-size: 11px; padding: 3px 10px; border-radius: 12px;
      background: #f0f0f0; font-family: monospace;
    }

    table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 15px 0; }
    th, td { padding: 10px 12px; text-align: left; border: 1px solid #dee2e6; }
    th { background: #34495e; color: white; }

    pre { background: #263238; color: #eceff1; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; line-height: 1.6; }
    .log-area { background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font-family: monospace; font-size: 12px; line-height: 1.6; max-height:200px;overflow-y:auto;margin-top:12px;}
  </style>
</head>
<body>
  <h1>📱 触摸拖拽 & 状态机演示</h1>

  <!-- ========== Touch Drag Canvas ========== -->
  <div class="section">
    <h2>1. 触摸事件拖拽画布</h2>
    <p style="font-size:13px;color:#666;margin-bottom:12px;">
      支持鼠标和触摸拖拽。在移动设备上也可以正常工作:
    </p>

    <div class="touch-canvas" id="touch-canvas">
      <div class="grid-bg"></div>
      <!-- 可拖拽元素由 JS 动态定位 -->
    </div>

    <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px;">
      <button style="background:#3498db;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="addChip()">➕ 添加元素</button>
      <button style="background:#e74c3c;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="resetChips()">🔄 重置位置</button>
      <button style="background:#95a5a6;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="randomizePositions()">🎲 随机排列</button>
    </div>

    <div class="log-area" id="drag-log">// 拖拽日志...</div>
  </div>

  <!-- ========== Drag State Machine ========== -->
  <div class="section">
    <h2>2. 拖拽状态机(State Machine)</h2>
    <p style="font-size:13px;color:#666;margin-bottom:12px;">
      一个完整的拖拽操作经历多个状态转换:
    </p>

    <div class="state-machine" id="state-machine">
      <div class="state-node" data-state="idle" id="st-idle">😴 空闲 idle</div>
      <div class="state-arrow">→</div>
      <div class="state-node" data-state="prepare" id="st-prepare">👆 准备 prepare</div>
      <div class="state-arrow">→</div>
      <div class="state-node" data-state="dragging" id="st-dragging">✋ 拖拽中 dragging</div>
      <div class="state-arrow">→</div>
      <div class="state-node" data-state="over-target" id="st-over">📍 悬停 over</div>
      <div class="state-arrow" style="transform:rotate(90deg);margin:4px 0;">↘</div>
      <div class="state-node" data-state="drop" id="st-drop">✅ 放置 drop</div>
      <div class="state-arrow" style="transform:rotate(90deg);margin:4px 0;">↙</div>
      <div class="state-node" data-state="end" id="st-end">🏁 结束 end</div>
    </div>

    <table>
      <thead><tr><th>当前状态</th><th>触发事件</th><th>下一状态</th></tr></thead>
      <tbody>
        <tr><td><code>idle</code></td><td>mousedown/touchstart</td><td><code>prepare</code></td></tr>
        <tr><td><code>prepare</code></td><td>mousemove ≥ 3px / touchmove</td><td><code>dragging</code></td></tr>
        <tr><td><code>prepare</code></td><td>mouseup/cancel</td><td><code>idle</code></td></tr>
        <tr><td><code>dragging</code></td><td>进入 drop zone</td><td><code>over-target</code></td></tr>
        <tr><td><code>dragging</code></td><td>mouseup (不在 zone)</td><td><code>end</code></td></tr>
        <tr><td><code>over-target</code></td><td>mouseup</td><td><code>drop</code></td></tr>
        <tr><td><code>over-target</code></td><td>离开 zone</td><td><code>dragging</code></td></tr>
        <tr><td><code>any</code></td><td>Escape 键</td><td><code>idle</code> (cancel)</td></tr>
      </tbody>
    </table>
  </div>

  <!-- ========== touch-action ========== -->
  <div class="section">
    <h2>3. touch-action CSS 属性对比</h2>
    <p style="font-size:13px;color:#666;margin-bottom:12px;">
      尝试在下方各个区域内滑动手指,观察不同的触摸行为:
    </p>

    <div class="ta-demo">
      <div class="ta-box" style="touch-action:auto;">
        <span class="swipe-icon">↕️</span>
        <span>auto(默认)<br>允许所有手势</span>
        <span class="action-label">touch-action: auto</span>
      </div>
      <div class="ta-box" style="touch-action:none;">
        <span class="swipe-icon">🚫</span>
        <span>none<br>禁止所有手势</span>
        <span class="action-label">touch-action: none</span>
      </div>
      <div class="ta-box" style="touch-action:pan-x;">
        <span class="swipe-icon">↔️</span>
        <span>pan-x<br>仅允许水平滑动</span>
        <span class="action-label">touch-action: pan-x</span>
      </div>
      <div class="ta-box" style="touch-action:pan-y;">
        <span class="swipe-icon">↕️</span>
        <span>pan-y<br>仅允许垂直滑动</span>
        <span class="action-label">touch-action: pan-y</span>
      </div>
      <div class="ta-box" style="touch-action:manipulation;">
        <span class="swipe-icon">👆</span>
        <span>manipulation<br>允许缩放但不双击放大</span>
        <span class="action-label">touch-action: manipulation</span>
      </div>
      <div class="ta-box" style="touch-action:pinch-zoom;">
        <span class="swipe-icon">🤏</span>
        <span>pinch-zoom<br>仅允许双指缩放</span>
        <span class="action-label">touch-action: pinch-zoom</span>
      </div>
    </div>

    <pre style="font-size:11px;">/* touch-action 最佳实践 */

/* 自定义拖拽元素 — 禁止浏览器默认行为 */
.draggable-element {
  touch-action: none;       /* 关键!阻止默认滚动/缩放 */
  user-select: none;
}

/* 可滚动容器内的拖拽项 */
.scrollable-container .drag-item {
  touch-action: pan-y;        /* 保留垂直滚动 */
  /* 不设 pan-x → 水平方向交给 JS 处理 */
}

/* 按钮和交互控件 */
button, a, input {
  touch-action: manipulation;  /* 允许缩放,禁用双击缩放 */
  /* 提升点击响应速度 ~300ms */
}</pre>
  </div>

  <script>
    // ===== Touch Drag Canvas =====
    const canvas = document.getElementById('touch-canvas');
    const log = document.getElementById('drag-log');
    let chipIdCounter = 0;
    const colors = [
      'linear-gradient(135deg, #667eea, #764ba2)',
      'linear-gradient(135deg, #f093fb, #f5576c)',
      'linear-gradient(135deg, #4facfe, #00f2fe)',
      'linear-gradient(135deg, #43e97b, #38f9d7)',
      'linear-gradient(135deg, #fa709a, #fee140)',
      'linear-gradient(135deg, #a18cd1, #fbc2eb)',
    ];
    const icons = ['🎯', '⭐', '🔥', '💡', '🎨', '🚀', '💎', '🌟', '🎮', '🎵'];

    function logDrag(msg) {
      log.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + log.textContent;
    }

    function createChip(x, y) {
      const chip = document.createElement('div');
      chip.className = 'draggable-chip';
      chip.id = `chip-${chipIdCounter++}`;
      chip.style.left = x + 'px';
      chip.style.top = y + 'px';
      chip.style.background = colors[chipIdCounter % colors.length];
      chip.innerHTML = `${icons[chipIdCounter % icons.length]} 芯片 ${chipIdCounter}`;

      // 绑定指针事件(统一处理 mouse + touch)
      bindPointerEvents(chip);
      canvas.appendChild(chip);
      return chip;
    }

    function bindPointerEvents(el) {
      let startX, startY, initialX, initialY, isDragging = false;

      const startDrag = (clientX, clientY) => {
        isDragging = true;
        startX = clientX;
        startY = clientY;
        initialX = el.offsetLeft;
        initialY = el.offsetTop;
        el.classList.add('dragging');
        logDrag(`[start] 开始拖拽 ${el.id} at (${Math.round(initialX)}, ${Math.round(initialY)})`);

        // 更新状态机
        updateState('prepare');
        setTimeout(() => updateState('dragging'), 300);
      };

      const moveDrag = (clientX, clientY) => {
        if (!isDragging) return;
        const dx = clientX - startX;
        const dy = clientY - startY;

        // 边界限制
        const canvasRect = canvas.getBoundingClientRect();
        const newLeft = Math.max(0, Math.min(canvasRect.width - el.offsetWidth, initialX + dx));
        const newTop = Math.max(0, Math.min(canvasRect.height - el.offsetHeight, initialY + dy));

        el.style.left = newLeft + 'px';
        el.style.top = newTop + 'px';

        // 检测是否在边界附近
        if (newLeft <= 0 || newLeft >= canvasRect.width - el.offsetWidth ||
            newTop <= 0 || newTop >= canvasRect.height - el.offsetHeight) {
          updateState('over-target');
        } else {
          updateState('dragging');
        }
      };

      const endDrag = () => {
        if (!isDragging) return;
        isDragging = false;
        el.classList.remove('dragging');

        const finalX = Math.round(el.offsetLeft);
        const finalY = Math.round(el.offsetTop);

        updateState('end');
        setTimeout(() => updateState('idle'), 800);

        logDrag(`[end] 放置 ${el.id} at (${finalX}, ${finalY})`);
      };

      // Mouse events
      el.addEventListener('mousedown', (e) => {
        e.preventDefault();
        startDrag(e.clientX, e.clientY);
      });
      document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
      document.addEventListener('mouseup', endDrag);

      // Touch events
      el.addEventListener('touchstart', (e) => {
        e.preventDefault();
        const t = e.touches[0];
        startDrag(t.clientX, t.clientY);
      }, { passive: false });
      document.addEventListener('touchmove', (e) => {
        if (!isDragging) return;
        e.preventDefault(); // 阻止页面滚动
        const t = e.touches[0];
        moveDrag(t.clientX, t.clientY);
      }, { passive: false });
      document.addEventListener('touchend', endDrag);
      document.addEventListener('touchcancel', endDrag);
    }

    function addChip() {
      const rect = canvas.getBoundingClientRect();
      const x = Math.random() * (rect.width - 140) + 10;
      const y = Math.random() * (rect.height - 50) + 10;
      createChip(x, y);
    }

    function resetChips() {
      const chips = canvas.querySelectorAll('.draggable-chip');
      const positions = [
        [20, 20], [160, 80], [320, 30], [480, 100],
        [80, 180], [240, 220], [400, 170]
      ];
      chips.forEach((chip, i) => {
        const pos = positions[i % positions.length];
        chip.style.left = pos[0] + 'px';
        chip.style.top = pos[1] + 'px';
      });
      logDrag('[reset] 所有元素已重置到初始位置');
    }

    function randomizePositions() {
      const chips = canvas.querySelectorAll('.draggable-chip');
      const rect = canvas.getBoundingClientRect();
      chips.forEach(chip => {
        chip.style.left = Math.random() * (rect.width - 140) + 10 + 'px';
        chip.style.top = Math.random() * (rect.height - 50) + 10 + 'px';
      });
      logDrag('[shuffle] 元素位置已随机打乱');
    }

    // 初始化一些芯片
    for (let i = 0; i < 5; i++) addChip();

    // ===== State Machine =====
    function updateState(stateName) {
      document.querySelectorAll('.state-node').forEach(node => {
        node.classList.toggle('active', node.dataset.state === stateName);
      });
    }

    // 初始状态
    updateState('idle');
  </script>
</body>
</html>