拖放API

拖放API概述

拖放API(Drag and Drop API)是HTML5提供的接口,允许用户通过拖拽的方式移动元素或数据。这个API使得Web应用可以实现类似桌面应用的拖放交互体验。

东巴文(db-w.cn) 认为:拖放API让Web交互更加直观和自然,大大提升了用户体验。

拖放API特点

核心特点

特点 说明
原生支持 HTML5原生API,无需额外库
跨元素 可以在不同元素间拖放
跨窗口 支持跨窗口拖放
数据传递 支持多种数据类型传递

拖放流程

拖动源元素 → 拖动过程 → 放置目标元素
   ↓            ↓              ↓
dragstart    drag          dragenter
drag         dragover      dragover
dragend      drop          drop

基本拖放

可拖动元素

<!-- 设置元素可拖动 -->
<div draggable="true">我可以被拖动</div>

<!-- 默认可拖动的元素 -->
<img src="image.jpg" alt="图片默认可拖动">
<a href="url">链接默认可拖动</a>

拖放事件

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>基本拖放示例</title>
    <style>
        .draggable {
            width: 100px;
            height: 100px;
            background: #667eea;
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: move;
            margin: 10px;
            border-radius: 5px;
        }
        
        .dropzone {
            width: 200px;
            height: 200px;
            border: 2px dashed #ccc;
            display: flex;
            align-items: center;
            justify-content: center;
            margin: 10px;
            border-radius: 5px;
        }
        
        .dropzone.dragover {
            border-color: #667eea;
            background: rgba(102, 126, 234, 0.1);
        }
    </style>
</head>
<body>
    <div id="draggable" class="draggable" draggable="true">
        拖动我
    </div>
    
    <div id="dropzone" class="dropzone">
        放置区域
    </div>
    
    <script>
        const draggable = document.getElementById('draggable');
        const dropzone = document.getElementById('dropzone');
        
        // 拖动开始
        draggable.addEventListener('dragstart', function(e) {
            console.log('dragstart');
            
            // 设置拖动数据
            e.dataTransfer.setData('text/plain', e.target.id);
            
            // 设置拖动效果
            e.dataTransfer.effectAllowed = 'move';
            
            // 设置拖动图像(可选)
            // e.dataTransfer.setDragImage(image, xOffset, yOffset);
        });
        
        // 拖动过程
        draggable.addEventListener('drag', function(e) {
            console.log('drag');
        });
        
        // 拖动结束
        draggable.addEventListener('dragend', function(e) {
            console.log('dragend');
        });
        
        // 进入放置区域
        dropzone.addEventListener('dragenter', function(e) {
            console.log('dragenter');
            e.preventDefault();
            this.classList.add('dragover');
        });
        
        // 在放置区域上方
        dropzone.addEventListener('dragover', function(e) {
            console.log('dragover');
            e.preventDefault(); // 必须阻止默认行为
            e.dataTransfer.dropEffect = 'move';
        });
        
        // 离开放置区域
        dropzone.addEventListener('dragleave', function(e) {
            console.log('dragleave');
            this.classList.remove('dragover');
        });
        
        // 放置
        dropzone.addEventListener('drop', function(e) {
            console.log('drop');
            e.preventDefault();
            
            this.classList.remove('dragover');
            
            // 获取拖动数据
            const id = e.dataTransfer.getData('text/plain');
            const element = document.getElementById(id);
            
            // 移动元素
            this.appendChild(element);
        });
    </script>
</body>
</html>

拖放事件详解

拖动源事件

事件 触发时机 说明
dragstart 开始拖动 设置拖动数据和效果
drag 拖动过程中 持续触发(高频)
dragend 拖动结束 清理工作

放置目标事件

事件 触发时机 说明
dragenter 进入目标 判断是否可放置
dragover 在目标上方 必须阻止默认行为
dragleave 离开目标 移除视觉反馈
drop 放置 处理放置逻辑

东巴文点评dragover事件必须调用e.preventDefault(),否则无法触发drop事件。

DataTransfer对象

属性

element.addEventListener('dragstart', function(e) {
    const dt = e.dataTransfer;
    
    // 设置拖动效果
    dt.effectAllowed = 'move'; // none, copy, move, link, copyMove, copyLink, moveLink, all
    
    // 设置数据
    dt.setData('text/plain', '文本数据');
    dt.setData('text/uri-list', 'http://example.com');
    dt.setData('application/json', JSON.stringify({ id: 1 }));
    
    // 设置拖动图像
    const img = new Image();
    img.src = 'drag-image.png';
    dt.setDragImage(img, 0, 0);
});

dropzone.addEventListener('dragover', function(e) {
    const dt = e.dataTransfer;
    
    // 获取拖动效果
    console.log(dt.effectAllowed);
    
    // 设置放置效果
    dt.dropEffect = 'move'; // none, copy, move, link
    
    // 获取数据类型
    console.log(dt.types);
    
    // 获取文件列表
    console.log(dt.files);
});

dropzone.addEventListener('drop', function(e) {
    const dt = e.dataTransfer;
    
    // 获取数据
    const text = dt.getData('text/plain');
    const url = dt.getData('text/uri-list');
    const json = dt.getData('application/json');
    
    // 获取所有数据
    dt.types.forEach(type => {
        console.log(type, dt.getData(type));
    });
});

方法

方法 说明
setData(format, data) 设置拖动数据
getData(format) 获取拖动数据
clearData([format]) 清除数据
setDragImage(img, x, y) 设置拖动图像

effectAllowed和dropEffect

// 拖动源设置允许的效果
draggable.addEventListener('dragstart', function(e) {
    e.dataTransfer.effectAllowed = 'copy'; // 只允许复制
});

// 放置目标设置实际效果
dropzone.addEventListener('dragover', function(e) {
    e.preventDefault();
    
    // 根据effectAllowed设置dropEffect
    if (e.dataTransfer.effectAllowed === 'copy') {
        e.dataTransfer.dropEffect = 'copy';
    }
});
effectAllowed值 说明
none 不允许任何操作
copy 复制
move 移动
link 链接
copyMove 复制或移动
copyLink 复制或链接
moveLink 移动或链接
all 所有操作

东巴文点评effectAlloweddropEffect用于控制拖放操作的视觉反馈,应该保持一致。

文件拖放

拖放文件上传

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>文件拖放上传</title>
    <style>
        .dropzone {
            width: 400px;
            height: 200px;
            border: 2px dashed #ccc;
            border-radius: 10px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            margin: 20px;
            transition: all 0.3s;
        }
        
        .dropzone.dragover {
            border-color: #667eea;
            background: rgba(102, 126, 234, 0.1);
            transform: scale(1.02);
        }
        
        .dropzone-icon {
            font-size: 48px;
            color: #ccc;
            margin-bottom: 10px;
        }
        
        .dropzone-text {
            color: #666;
            font-size: 16px;
        }
        
        .file-list {
            margin: 20px;
        }
        
        .file-item {
            display: flex;
            align-items: center;
            padding: 10px;
            margin: 5px 0;
            background: #f5f5f5;
            border-radius: 5px;
        }
        
        .file-icon {
            font-size: 24px;
            margin-right: 10px;
        }
        
        .file-info {
            flex: 1;
        }
        
        .file-name {
            font-weight: bold;
        }
        
        .file-size {
            font-size: 12px;
            color: #999;
        }
        
        .file-progress {
            width: 100px;
            height: 5px;
            background: #e0e0e0;
            border-radius: 3px;
            overflow: hidden;
        }
        
        .file-progress-bar {
            height: 100%;
            background: #667eea;
            transition: width 0.3s;
        }
    </style>
</head>
<body>
    <div id="dropzone" class="dropzone">
        <div class="dropzone-icon">📁</div>
        <div class="dropzone-text">拖放文件到这里上传</div>
        <div class="dropzone-text" style="font-size: 12px; color: #999; margin-top: 5px;">
            或点击选择文件
        </div>
        <input type="file" id="fileInput" multiple style="display: none;">
    </div>
    
    <div id="fileList" class="file-list"></div>
    
    <script>
        const dropzone = document.getElementById('dropzone');
        const fileInput = document.getElementById('fileInput');
        const fileList = document.getElementById('fileList');
        
        // 点击选择文件
        dropzone.addEventListener('click', function() {
            fileInput.click();
        });
        
        fileInput.addEventListener('change', function(e) {
            handleFiles(e.target.files);
        });
        
        // 拖放事件
        dropzone.addEventListener('dragenter', function(e) {
            e.preventDefault();
            this.classList.add('dragover');
        });
        
        dropzone.addEventListener('dragover', function(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'copy';
        });
        
        dropzone.addEventListener('dragleave', function(e) {
            e.preventDefault();
            this.classList.remove('dragover');
        });
        
        dropzone.addEventListener('drop', function(e) {
            e.preventDefault();
            this.classList.remove('dragover');
            
            const files = e.dataTransfer.files;
            handleFiles(files);
        });
        
        // 处理文件
        function handleFiles(files) {
            Array.from(files).forEach(file => {
                addFileToList(file);
                uploadFile(file);
            });
        }
        
        // 添加文件到列表
        function addFileToList(file) {
            const item = document.createElement('div');
            item.className = 'file-item';
            item.id = `file-${Date.now()}-${Math.random()}`;
            
            const icon = getFileIcon(file.type);
            const size = formatFileSize(file.size);
            
            item.innerHTML = `
                <div class="file-icon">${icon}</div>
                <div class="file-info">
                    <div class="file-name">${file.name}</div>
                    <div class="file-size">${size}</div>
                </div>
                <div class="file-progress">
                    <div class="file-progress-bar" style="width: 0%"></div>
                </div>
            `;
            
            fileList.appendChild(item);
        }
        
        // 上传文件(模拟)
        function uploadFile(file) {
            const formData = new FormData();
            formData.append('file', file);
            
            // 模拟上传进度
            let progress = 0;
            const interval = setInterval(() => {
                progress += 10;
                
                const progressBar = document.querySelector('.file-progress-bar');
                if (progressBar) {
                    progressBar.style.width = `${progress}%`;
                }
                
                if (progress >= 100) {
                    clearInterval(interval);
                    console.log(`${file.name} 上传完成`);
                }
            }, 200);
            
            // 实际上传代码
            // fetch('/upload', {
            //     method: 'POST',
            //     body: formData
            // });
        }
        
        // 获取文件图标
        function getFileIcon(type) {
            if (type.startsWith('image/')) return '🖼️';
            if (type.startsWith('video/')) return '🎬';
            if (type.startsWith('audio/')) return '🎵';
            if (type.includes('pdf')) return '📄';
            if (type.includes('word') || type.includes('document')) return '📝';
            if (type.includes('excel') || type.includes('sheet')) return '📊';
            if (type.includes('zip') || type.includes('rar')) return '📦';
            return '📁';
        }
        
        // 格式化文件大小
        function formatFileSize(bytes) {
            if (bytes === 0) return '0 B';
            
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</body>
</html>

东巴文点评:文件拖放是拖放API最常见的应用场景,应该提供清晰的视觉反馈和进度提示。

拖放排序

列表排序

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>拖放排序</title>
    <style>
        .sortable-list {
            list-style: none;
            padding: 0;
            max-width: 400px;
        }
        
        .sortable-item {
            padding: 15px;
            margin: 5px 0;
            background: white;
            border: 1px solid #ddd;
            border-radius: 5px;
            cursor: move;
            display: flex;
            align-items: center;
            transition: all 0.3s;
        }
        
        .sortable-item:hover {
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
        
        .sortable-item.dragging {
            opacity: 0.5;
            background: #f0f0f0;
        }
        
        .sortable-item.drag-over {
            border-top: 2px solid #667eea;
        }
        
        .item-handle {
            margin-right: 10px;
            color: #999;
        }
        
        .item-content {
            flex: 1;
        }
    </style>
</head>
<body>
    <h2>拖放排序列表</h2>
    
    <ul id="sortableList" class="sortable-list">
        <li class="sortable-item" draggable="true" data-id="1">
            <span class="item-handle"></span>
            <span class="item-content">项目 1</span>
        </li>
        <li class="sortable-item" draggable="true" data-id="2">
            <span class="item-handle"></span>
            <span class="item-content">项目 2</span>
        </li>
        <li class="sortable-item" draggable="true" data-id="3">
            <span class="item-handle"></span>
            <span class="item-content">项目 3</span>
        </li>
        <li class="sortable-item" draggable="true" data-id="4">
            <span class="item-handle"></span>
            <span class="item-content">项目 4</span>
        </li>
        <li class="sortable-item" draggable="true" data-id="5">
            <span class="item-handle"></span>
            <span class="item-content">项目 5</span>
        </li>
    </ul>
    
    <script>
        const list = document.getElementById('sortableList');
        let draggedItem = null;
        
        list.addEventListener('dragstart', function(e) {
            if (e.target.classList.contains('sortable-item')) {
                draggedItem = e.target;
                e.target.classList.add('dragging');
                
                e.dataTransfer.effectAllowed = 'move';
                e.dataTransfer.setData('text/plain', e.target.dataset.id);
            }
        });
        
        list.addEventListener('dragend', function(e) {
            if (e.target.classList.contains('sortable-item')) {
                e.target.classList.remove('dragging');
                
                // 移除所有drag-over类
                document.querySelectorAll('.sortable-item').forEach(item => {
                    item.classList.remove('drag-over');
                });
                
                draggedItem = null;
            }
        });
        
        list.addEventListener('dragover', function(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'move';
            
            const target = e.target.closest('.sortable-item');
            
            if (target && target !== draggedItem) {
                // 移除所有drag-over类
                document.querySelectorAll('.sortable-item').forEach(item => {
                    item.classList.remove('drag-over');
                });
                
                // 添加drag-over类到目标元素
                target.classList.add('drag-over');
            }
        });
        
        list.addEventListener('drop', function(e) {
            e.preventDefault();
            
            const target = e.target.closest('.sortable-item');
            
            if (target && target !== draggedItem) {
                // 获取所有列表项
                const items = Array.from(list.querySelectorAll('.sortable-item'));
                const draggedIndex = items.indexOf(draggedItem);
                const targetIndex = items.indexOf(target);
                
                // 重新排序
                if (draggedIndex < targetIndex) {
                    target.parentNode.insertBefore(draggedItem, target.nextSibling);
                } else {
                    target.parentNode.insertBefore(draggedItem, target);
                }
                
                // 保存排序
                saveOrder();
            }
        });
        
        // 保存排序
        function saveOrder() {
            const items = list.querySelectorAll('.sortable-item');
            const order = Array.from(items).map(item => item.dataset.id);
            
            console.log('新顺序:', order);
            
            // 发送到服务器
            // fetch('/api/reorder', {
            //     method: 'POST',
            //     headers: { 'Content-Type': 'application/json' },
            //     body: JSON.stringify({ order })
            // });
        }
    </script>
</body>
</html>

综合示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>拖放API综合示例 - 东巴文</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
            background: #f5f5f5;
        }
        
        h1 {
            text-align: center;
            color: #333;
        }
        
        .section {
            margin: 20px 0;
            padding: 20px;
            background: white;
            border-radius: 10px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
        
        .section h2 {
            margin-top: 0;
            color: #667eea;
        }
        
        /* 看板样式 */
        .kanban {
            display: flex;
            gap: 20px;
            overflow-x: auto;
        }
        
        .kanban-column {
            flex: 1;
            min-width: 250px;
            background: #f9f9f9;
            border-radius: 8px;
            padding: 15px;
        }
        
        .kanban-column h3 {
            margin: 0 0 15px 0;
            padding-bottom: 10px;
            border-bottom: 2px solid #667eea;
        }
        
        .kanban-card {
            background: white;
            padding: 15px;
            margin: 10px 0;
            border-radius: 5px;
            box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
            cursor: move;
            transition: all 0.3s;
        }
        
        .kanban-card:hover {
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
            transform: translateY(-2px);
        }
        
        .kanban-card.dragging {
            opacity: 0.5;
        }
        
        .kanban-column.drag-over {
            background: rgba(102, 126, 234, 0.1);
        }
        
        .card-title {
            font-weight: bold;
            margin-bottom: 8px;
        }
        
        .card-description {
            font-size: 14px;
            color: #666;
            margin-bottom: 10px;
        }
        
        .card-tags {
            display: flex;
            gap: 5px;
            flex-wrap: wrap;
        }
        
        .tag {
            padding: 2px 8px;
            border-radius: 3px;
            font-size: 12px;
            color: white;
        }
        
        .tag.priority-high { background: #e74c3c; }
        .tag.priority-medium { background: #f39c12; }
        .tag.priority-low { background: #27ae60; }
        
        /* 文件上传样式 */
        .upload-area {
            border: 2px dashed #ccc;
            border-radius: 10px;
            padding: 40px;
            text-align: center;
            transition: all 0.3s;
        }
        
        .upload-area.drag-over {
            border-color: #667eea;
            background: rgba(102, 126, 234, 0.05);
        }
        
        .upload-icon {
            font-size: 48px;
            color: #ccc;
        }
        
        .upload-text {
            margin: 10px 0;
            color: #666;
        }
        
        .preview-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
            gap: 15px;
            margin-top: 20px;
        }
        
        .preview-item {
            position: relative;
            aspect-ratio: 1;
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
        
        .preview-item img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }
        
        .preview-item .remove-btn {
            position: absolute;
            top: 5px;
            right: 5px;
            width: 24px;
            height: 24px;
            background: rgba(0, 0, 0, 0.6);
            color: white;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        
        /* 购物车样式 */
        .shop-container {
            display: flex;
            gap: 20px;
        }
        
        .products {
            flex: 2;
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
            gap: 15px;
        }
        
        .product {
            background: white;
            padding: 15px;
            border-radius: 8px;
            text-align: center;
            cursor: move;
            transition: all 0.3s;
        }
        
        .product:hover {
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        }
        
        .product.dragging {
            opacity: 0.5;
        }
        
        .product-icon {
            font-size: 48px;
            margin-bottom: 10px;
        }
        
        .product-name {
            font-weight: bold;
            margin-bottom: 5px;
        }
        
        .product-price {
            color: #667eea;
            font-weight: bold;
        }
        
        .cart {
            flex: 1;
            background: #f9f9f9;
            border-radius: 8px;
            padding: 20px;
            min-height: 300px;
        }
        
        .cart.drag-over {
            background: rgba(102, 126, 234, 0.1);
        }
        
        .cart h3 {
            margin-top: 0;
            padding-bottom: 10px;
            border-bottom: 2px solid #667eea;
        }
        
        .cart-item {
            display: flex;
            align-items: center;
            padding: 10px;
            margin: 5px 0;
            background: white;
            border-radius: 5px;
        }
        
        .cart-item-icon {
            font-size: 24px;
            margin-right: 10px;
        }
        
        .cart-item-info {
            flex: 1;
        }
        
        .cart-total {
            margin-top: 15px;
            padding-top: 15px;
            border-top: 1px solid #ddd;
            font-size: 18px;
            font-weight: bold;
            text-align: right;
        }
    </style>
</head>
<body>
    <h1>拖放API综合示例</h1>
    
    <!-- 看板示例 -->
    <div class="section">
        <h2>📋 任务看板</h2>
        <div class="kanban" id="kanban">
            <div class="kanban-column" data-status="todo">
                <h3>待办</h3>
                <div class="kanban-card" draggable="true" data-id="1">
                    <div class="card-title">设计首页</div>
                    <div class="card-description">完成首页UI设计稿</div>
                    <div class="card-tags">
                        <span class="tag priority-high">高优先级</span>
                    </div>
                </div>
                <div class="kanban-card" draggable="true" data-id="2">
                    <div class="card-title">编写文档</div>
                    <div class="card-description">编写API接口文档</div>
                    <div class="card-tags">
                        <span class="tag priority-medium">中优先级</span>
                    </div>
                </div>
            </div>
            
            <div class="kanban-column" data-status="doing">
                <h3>进行中</h3>
                <div class="kanban-card" draggable="true" data-id="3">
                    <div class="card-title">开发登录功能</div>
                    <div class="card-description">实现用户登录模块</div>
                    <div class="card-tags">
                        <span class="tag priority-high">高优先级</span>
                    </div>
                </div>
            </div>
            
            <div class="kanban-column" data-status="done">
                <h3>已完成</h3>
                <div class="kanban-card" draggable="true" data-id="4">
                    <div class="card-title">需求分析</div>
                    <div class="card-description">完成需求分析文档</div>
                    <div class="card-tags">
                        <span class="tag priority-low">低优先级</span>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <!-- 文件上传示例 -->
    <div class="section">
        <h2>📁 文件拖放上传</h2>
        <div class="upload-area" id="uploadArea">
            <div class="upload-icon">📤</div>
            <div class="upload-text">拖放图片到这里上传</div>
            <div class="upload-text" style="font-size: 12px; color: #999;">
                支持 JPG、PNG、GIF 格式
            </div>
        </div>
        <div class="preview-grid" id="previewGrid"></div>
    </div>
    
    <!-- 购物车示例 -->
    <div class="section">
        <h2>🛒 拖放购物车</h2>
        <div class="shop-container">
            <div class="products" id="products">
                <div class="product" draggable="true" data-id="1" data-name="苹果" data-price="5">
                    <div class="product-icon">🍎</div>
                    <div class="product-name">苹果</div>
                    <div class="product-price">¥5</div>
                </div>
                <div class="product" draggable="true" data-id="2" data-name="香蕉" data-price="3">
                    <div class="product-icon">🍌</div>
                    <div class="product-name">香蕉</div>
                    <div class="product-price">¥3</div>
                </div>
                <div class="product" draggable="true" data-id="3" data-name="橙子" data-price="4">
                    <div class="product-icon">🍊</div>
                    <div class="product-name">橙子</div>
                    <div class="product-price">¥4</div>
                </div>
                <div class="product" draggable="true" data-id="4" data-name="葡萄" data-price="8">
                    <div class="product-icon">🍇</div>
                    <div class="product-name">葡萄</div>
                    <div class="product-price">¥8</div>
                </div>
            </div>
            
            <div class="cart" id="cart">
                <h3>购物车</h3>
                <div id="cartItems"></div>
                <div class="cart-total" id="cartTotal">总计: ¥0</div>
            </div>
        </div>
    </div>
    
    <script>
        // ========== 看板拖放 ==========
        const kanban = document.getElementById('kanban');
        let draggedCard = null;
        
        kanban.addEventListener('dragstart', function(e) {
            if (e.target.classList.contains('kanban-card')) {
                draggedCard = e.target;
                e.target.classList.add('dragging');
                e.dataTransfer.effectAllowed = 'move';
            }
        });
        
        kanban.addEventListener('dragend', function(e) {
            if (e.target.classList.contains('kanban-card')) {
                e.target.classList.remove('dragging');
                draggedCard = null;
                
                document.querySelectorAll('.kanban-column').forEach(col => {
                    col.classList.remove('drag-over');
                });
            }
        });
        
        kanban.addEventListener('dragover', function(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'move';
            
            const column = e.target.closest('.kanban-column');
            if (column) {
                column.classList.add('drag-over');
            }
        });
        
        kanban.addEventListener('dragleave', function(e) {
            const column = e.target.closest('.kanban-column');
            if (column && !column.contains(e.relatedTarget)) {
                column.classList.remove('drag-over');
            }
        });
        
        kanban.addEventListener('drop', function(e) {
            e.preventDefault();
            
            const column = e.target.closest('.kanban-column');
            if (column && draggedCard) {
                column.appendChild(draggedCard);
                column.classList.remove('drag-over');
                
                console.log(`任务 ${draggedCard.dataset.id} 移动到 ${column.dataset.status}`);
            }
        });
        
        // ========== 文件上传 ==========
        const uploadArea = document.getElementById('uploadArea');
        const previewGrid = document.getElementById('previewGrid');
        
        uploadArea.addEventListener('dragenter', function(e) {
            e.preventDefault();
            this.classList.add('drag-over');
        });
        
        uploadArea.addEventListener('dragover', function(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'copy';
        });
        
        uploadArea.addEventListener('dragleave', function(e) {
            e.preventDefault();
            this.classList.remove('drag-over');
        });
        
        uploadArea.addEventListener('drop', function(e) {
            e.preventDefault();
            this.classList.remove('drag-over');
            
            const files = e.dataTransfer.files;
            handleImageFiles(files);
        });
        
        function handleImageFiles(files) {
            Array.from(files).forEach(file => {
                if (!file.type.startsWith('image/')) {
                    alert('只支持图片文件');
                    return;
                }
                
                const reader = new FileReader();
                reader.onload = function(e) {
                    const preview = document.createElement('div');
                    preview.className = 'preview-item';
                    preview.innerHTML = `
                        <img src="${e.target.result}" alt="${file.name}">
                        <button class="remove-btn" onclick="this.parentElement.remove()">×</button>
                    `;
                    previewGrid.appendChild(preview);
                };
                reader.readAsDataURL(file);
            });
        }
        
        // ========== 购物车 ==========
        const products = document.getElementById('products');
        const cart = document.getElementById('cart');
        const cartItems = document.getElementById('cartItems');
        const cartTotal = document.getElementById('cartTotal');
        
        let cartData = [];
        let draggedProduct = null;
        
        products.addEventListener('dragstart', function(e) {
            if (e.target.classList.contains('product')) {
                draggedProduct = e.target;
                e.target.classList.add('dragging');
                e.dataTransfer.effectAllowed = 'copy';
            }
        });
        
        products.addEventListener('dragend', function(e) {
            if (e.target.classList.contains('product')) {
                e.target.classList.remove('dragging');
                draggedProduct = null;
            }
        });
        
        cart.addEventListener('dragover', function(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'copy';
            this.classList.add('drag-over');
        });
        
        cart.addEventListener('dragleave', function(e) {
            this.classList.remove('drag-over');
        });
        
        cart.addEventListener('drop', function(e) {
            e.preventDefault();
            this.classList.remove('drag-over');
            
            if (draggedProduct) {
                const id = draggedProduct.dataset.id;
                const name = draggedProduct.dataset.name;
                const price = parseFloat(draggedProduct.dataset.price);
                
                // 添加到购物车
                const existingItem = cartData.find(item => item.id === id);
                if (existingItem) {
                    existingItem.quantity++;
                } else {
                    cartData.push({ id, name, price, quantity: 1 });
                }
                
                updateCartDisplay();
            }
        });
        
        function updateCartDisplay() {
            cartItems.innerHTML = cartData.map(item => `
                <div class="cart-item">
                    <span class="cart-item-icon">${getProductIcon(item.name)}</span>
                    <div class="cart-item-info">
                        <div>${item.name} × ${item.quantity}</div>
                        <div style="color: #667eea;">¥${item.price * item.quantity}</div>
                    </div>
                </div>
            `).join('');
            
            const total = cartData.reduce((sum, item) => sum + item.price * item.quantity, 0);
            cartTotal.textContent = `总计: ¥${total}`;
        }
        
        function getProductIcon(name) {
            const icons = {
                '苹果': '🍎',
                '香蕉': '🍌',
                '橙子': '🍊',
                '葡萄': '🍇'
            };
            return icons[name] || '📦';
        }
    </script>
</body>
</html>

最佳实践

1. 提供视觉反馈

// 推荐:提供清晰的视觉反馈
element.addEventListener('dragenter', function(e) {
    this.classList.add('drag-over');
});

element.addEventListener('dragleave', function(e) {
    this.classList.remove('drag-over');
});

// 不推荐:没有视觉反馈
element.addEventListener('dragover', function(e) {
    e.preventDefault();
});

2. 设置拖动图像

// 推荐:自定义拖动图像
element.addEventListener('dragstart', function(e) {
    const img = new Image();
    img.src = 'drag-preview.png';
    e.dataTransfer.setDragImage(img, 0, 0);
});

// 不推荐:使用默认拖动图像
element.addEventListener('dragstart', function(e) {
    // 没有设置拖动图像
});

3. 正确处理文件类型

// 推荐:检查文件类型
dropzone.addEventListener('drop', function(e) {
    const files = e.dataTransfer.files;
    
    Array.from(files).forEach(file => {
        if (file.type.startsWith('image/')) {
            processImage(file);
        } else {
            alert('只支持图片文件');
        }
    });
});

// 不推荐:不检查文件类型
dropzone.addEventListener('drop', function(e) {
    const files = e.dataTransfer.files;
    processFiles(files); // 可能处理了不支持的文件类型
});

东巴文点评:良好的用户体验需要提供清晰的视觉反馈和友好的错误提示。

学习检验

知识点测试

问题1:以下哪个事件必须调用preventDefault()才能触发drop事件?

A. dragstart B. dragenter C. dragover D. dragend

<details> <summary>点击查看答案</summary>

答案:C

东巴文解释dragover事件必须调用e.preventDefault(),否则无法触发drop事件。这是因为默认情况下,大多数元素不允许放置。

</details>

问题2dataTransfer.effectAllowed的作用是?

A. 设置放置效果 B. 设置允许的拖动效果 C. 设置拖动数据 D. 设置拖动图像

<details> <summary>点击查看答案</summary>

答案:B

东巴文解释effectAllowed用于设置拖动源允许的操作类型(如copy、move、link),而dropEffect用于设置放置目标的实际操作效果。

</details>

实践任务

任务:创建一个待办事项列表,支持拖放排序和拖放到垃圾桶删除。

<details> <summary>点击查看参考答案</summary>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>待办事项拖放管理</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 50px auto;
            padding: 20px;
        }
        
        .todo-list {
            list-style: none;
            padding: 0;
        }
        
        .todo-item {
            padding: 15px;
            margin: 5px 0;
            background: white;
            border: 1px solid #ddd;
            border-radius: 5px;
            cursor: move;
            display: flex;
            align-items: center;
            transition: all 0.3s;
        }
        
        .todo-item:hover {
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
        
        .todo-item.dragging {
            opacity: 0.5;
        }
        
        .todo-item.drag-over {
            border-top: 2px solid #667eea;
        }
        
        .todo-text {
            flex: 1;
        }
        
        .trash {
            margin-top: 20px;
            padding: 30px;
            border: 2px dashed #e74c3c;
            border-radius: 10px;
            text-align: center;
            color: #e74c3c;
            transition: all 0.3s;
        }
        
        .trash.drag-over {
            background: rgba(231, 76, 60, 0.1);
            border-color: #c0392b;
        }
        
        .trash-icon {
            font-size: 48px;
        }
        
        .add-form {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        
        .add-form input {
            flex: 1;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        
        .add-form button {
            padding: 10px 20px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>待办事项拖放管理</h1>
    
    <div class="add-form">
        <input type="text" id="todoInput" placeholder="添加新任务...">
        <button onclick="addTodo()">添加</button>
    </div>
    
    <ul id="todoList" class="todo-list"></ul>
    
    <div id="trash" class="trash">
        <div class="trash-icon">🗑️</div>
        <div>拖放到这里删除</div>
    </div>
    
    <script>
        const todoList = document.getElementById('todoList');
        const trash = document.getElementById('trash');
        const todoInput = document.getElementById('todoInput');
        
        let draggedItem = null;
        let todos = [
            { id: 1, text: '学习HTML5拖放API' },
            { id: 2, text: '完成项目文档' },
            { id: 3, text: '代码审查' }
        ];
        
        // 渲染列表
        function renderTodos() {
            todoList.innerHTML = todos.map(todo => `
                <li class="todo-item" draggable="true" data-id="${todo.id}">
                    <span class="todo-text">${todo.text}</span>
                </li>
            `).join('');
        }
        
        // 添加待办
        function addTodo() {
            const text = todoInput.value.trim();
            if (text) {
                todos.push({
                    id: Date.now(),
                    text
                });
                todoInput.value = '';
                renderTodos();
            }
        }
        
        // 回车添加
        todoInput.addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                addTodo();
            }
        });
        
        // 列表拖放事件
        todoList.addEventListener('dragstart', function(e) {
            if (e.target.classList.contains('todo-item')) {
                draggedItem = e.target;
                e.target.classList.add('dragging');
            }
        });
        
        todoList.addEventListener('dragend', function(e) {
            if (e.target.classList.contains('todo-item')) {
                e.target.classList.remove('dragging');
                draggedItem = null;
                
                document.querySelectorAll('.todo-item').forEach(item => {
                    item.classList.remove('drag-over');
                });
            }
        });
        
        todoList.addEventListener('dragover', function(e) {
            e.preventDefault();
            
            const target = e.target.closest('.todo-item');
            if (target && target !== draggedItem) {
                document.querySelectorAll('.todo-item').forEach(item => {
                    item.classList.remove('drag-over');
                });
                target.classList.add('drag-over');
            }
        });
        
        todoList.addEventListener('drop', function(e) {
            e.preventDefault();
            
            const target = e.target.closest('.todo-item');
            if (target && target !== draggedItem) {
                const items = Array.from(todoList.querySelectorAll('.todo-item'));
                const draggedIndex = items.indexOf(draggedItem);
                const targetIndex = items.indexOf(target);
                
                // 更新数据
                const [removed] = todos.splice(draggedIndex, 1);
                todos.splice(targetIndex, 0, removed);
                
                renderTodos();
            }
        });
        
        // 垃圾桶拖放事件
        trash.addEventListener('dragover', function(e) {
            e.preventDefault();
            this.classList.add('drag-over');
        });
        
        trash.addEventListener('dragleave', function(e) {
            this.classList.remove('drag-over');
        });
        
        trash.addEventListener('drop', function(e) {
            e.preventDefault();
            this.classList.remove('drag-over');
            
            if (draggedItem) {
                const id = parseInt(draggedItem.dataset.id);
                todos = todos.filter(todo => todo.id !== id);
                renderTodos();
            }
        });
        
        // 初始渲染
        renderTodos();
    </script>
</body>
</html>
</details>

东巴文(db-w.cn) - 让编程学习更有趣、更高效!