滑动框选是 Web 应用中常见的交互模式之一,广泛应用于操作系统资源管理器和各类在线文档管理系统中。通过简单的拖拽动作就能快速选择多个目标项,极大地提升了用户操作效率。本文将介绍如何使用 HTML、CSS 和 JavaScript 实现一个支持滑动框选功能的文件管理界面。效果演示
用户可以在网格区域内自由拖拽形成一个矩形区域来选择多个卡片,支持传统点击选中/取消单个元素,可以配合 Ctrl 或 Shift 键进行多选操作,顶部工具栏实时显示当前选中数量,并启用相应操作按钮。
页面结构
页面整体采用简洁清晰的设计风格,主要由三部分组成:头部操作栏、主体内容区和辅助选择框。
头部操作栏
头部包含标题、选中统计信息及批量操作按钮。这些按钮的状态会根据当前选中状态动态更新。<header> <h3>我的文件</h3> <span id="selectedInfo">已选 0 项</span> <button class="act" onclick="clearAll()">取消选择</button> <button class="act primary" id="delBtn" disabled onclick="batch('delete')">删除</button> <button class="act primary" id="downBtn" disabled onclick="batch('download')">下载</button></header>
主体内容区
这是放置所有文件卡片的核心容器,采用 CSS Grid 布局自动排列卡片。每张卡片代表一个文件或目录,具有图标、名称和复选框。
辅助选择框
这是一个绝对定位的透明层,仅在执行滑动框选时显示。其大小和位置完全跟随鼠标移动轨迹变化,用于视觉反馈和计算碰撞检测。<div id="selector"></div>
核心功能实现
数据准备与初始化
首先定义了模拟数据 fake 和图标映射表 iconMap,然后遍历生成 DOM 结构并填充初始内容。var fake = [ {name:'项目文档',type:'folder'}, {name:'照片.zip',type:'zip'}, // ...];var iconMap = { folder: '<svg viewBox="0 0 24 24" width="42" height="42"><path fill="#FFA000" d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"/></svg>', zip: '<svg viewBox="0 0 24 24" width="42" height="42"><path fill="#616161" d="M7 3H4v18h16V7l-3-3H7zm0 2v10h10V5H7zm2 2h6v2H9V7zm0 4h6v2H9v-2zm0 4h6v2H9v-2z"/></svg>', // ...};
var grid = document.getElementById('grid');var selector = document.getElementById('selector');var cards = [];fake.forEach((f,i)=>{ var card = document.createElement('div'); card.className = 'card'; card.innerHTML = `<div class="thumb"></div> <div class="name" title="${f.name}">${f.name}</div> <input type="checkbox" class="check">`; card.querySelector('.thumb').innerHTML = iconMap[f.type] || iconMap.default; card.dataset.idx = i; grid.appendChild(card); cards.push(card);});
选择状态管理
使用 Set 来维护当前被选中的索引集合,保证唯一性和高性能查找。每当选择发生变化都会调用 refreshUI() 方法同步更新 UI 层面的展示效果。var startEl = null;var selectedSet = new Set(); function refreshUI(){ cards.forEach(c=>{ c.classList.toggle('selected',selectedSet.has(+c.dataset.idx)); c.querySelector('.check').checked = selectedSet.has(+c.dataset.idx); }); var n = selectedSet.size; document.getElementById('selectedInfo').textContent = `已选 ${n} 项`; document.getElementById('delBtn').disabled = !n; document.getElementById('downBtn').disabled = !n;}
滑动框选机制
通过监听 mousedown/mousemove/mouseup 事件捕捉完整的滑动过程。在 mousemove 中不断调整 selector 的尺寸和位置,并对每个 card 进行碰撞检测。一旦发生重叠就将其加入选集。var selecting = 0, sx=0, sy=0, ex=0, ey=0;grid.addEventListener('mousedown',e=>{ if(e.target.closest('.card') && e.target.tagName !== 'INPUT') return; selecting = 1; var rect = grid.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; selector.style.left = sx + 'px'; selector.style.top = sy + 'px'; selector.style.width = selector.style.height = '0px'; selector.style.display = 'block';});document.addEventListener('mousemove',e=>{ if(!selecting) return; ex = e.clientX; ey = e.clientY; var x = Math.min(sx,ex), y = Math.min(sy,ey), w = Math.abs(ex-sx), h = Math.abs(ey-sy); selector.style.left = x + 'px'; selector.style.top = y + 'px'; selector.style.width = w + 'px'; selector.style.height = h + 'px'; var r1 = selector.getBoundingClientRect(); cards.forEach(c=>{ var r2 = c.getBoundingClientRect(); var collide = !(r1.right<r2.left||r1.left>r2.right||r1.bottom<r2.top||r1.top>r2.bottom); var idx = +c.dataset.idx; if(collide) selectedSet.add(idx); else selectedSet.delete(idx); }); refreshUI();});document.addEventListener('mouseup',e=>{ if(selecting){ selecting = 0; selector.style.display = 'none'; }});
键盘辅助选择
为了进一步增强可用性,还加入了标准的键盘快捷操作支持。单独点击 checkbox 触发切换行为,“Ctrl + 点击”可追加选择,“Shift + 点击”则会选择起始点至终点间的所有项目,普通点击清空之前的选择并选中新项目。grid.addEventListener('click',e=>{ var card = e.target.closest('.card'); if(!card) return; var idx = +card.dataset.idx; if(e.target.tagName === 'INPUT') { toggle(idx); startEl = card; } else if(e.ctrlKey||e.metaKey){ toggle(idx); startEl = card; } else if(e.shiftKey && startEl){ var a = +startEl.dataset.idx, b = idx; var min = Math.min(a,b), max = Math.max(a,b); selectedSet.clear(); for(var i=min;i<=max;i++) selectedSet.add(i); refreshUI(); } else{ selectedSet.clear(); selectedSet.add(idx); startEl = card; refreshUI(); }});
扩展建议
搜索过滤:添加搜索框帮助用户更快找到目标
右键菜单:添加右键菜单提供更多操作选项
模式切换:增加列表模式,允许用户根据喜好切换
拖拽排序:支持通过拖拽来重新排列文件顺序
完整代码
git地址:https://gitee.com/ironpro/hjdemo/blob/master/sliding-select/index.html<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="utf-8" /> <title>滑动框选</title> <meta name="viewport" content="width=device-width, initial-scale=1"/> <style> * { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } html, body { height: 100%; } body { display: flex; flex-direction: column; background: #f6f7f9; color: #333; } header { height: 52px; background: #fff; border-bottom: 1px solid #e5e5e5; display: flex; align-items: center; padding: 0 24px; position: sticky; top: 0; z-index: 9; flex-shrink: 0; } header h3 { font-size: 18px; font-weight: 500; margin-right: auto; } header .act { margin-left: 14px; padding: 6px 14px; border: 1px solid #d0d0d0; border-radius: 4px; background: #fff; font-size: 14px; cursor: pointer; } header .act.primary { background: #06a7ff; color: #fff; border-color: #06a7ff; } header .act:disabled { opacity: .5; cursor: not-allowed; } #selectedInfo { margin-left: 14px; font-size: 14px; color: #666; } #grid { flex: 1; display: grid; grid-template-columns: repeat(auto-fill, 118px); grid-auto-rows: 148px; gap: 14px; padding: 24px; overflow-y: auto; } .card { position: relative; width: 118px; height: 148px; background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; cursor: pointer; transition: transform .15s, box-shadow .15s; overflow: hidden; } .card:hover { box-shadow: 0 4px 14px rgba(0, 0, 0, .08); } .card .thumb { height: 88px; display: flex; align-items: center; justify-content: center; font-size: 42px; color: #999; } .card .name { font-size: 13px; padding: 0 8px; text-align: center; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; line-height: 20px; } .card .check { position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; cursor: pointer; z-index: 1; } .card.selected { border-color: #06a7ff !important; } #selector { position: absolute; border: 1px dashed #06a7ff; background: rgba(6, 167, 255, .08); display: none; pointer-events: none; z-index: 8; } </style></head><body><header> <h3>我的文件</h3> <span id="selectedInfo">已选 0 项</span> <button class="act" onclick="clearAll()">取消选择</button> <button class="act primary" id="delBtn" disabled onclick="batch('delete')">删除</button> <button class="act primary" id="downBtn" disabled onclick="batch('download')">下载</button></header><div id="grid"></div><div id="selector"></div><script> var fake = [ {name:'项目文档',type:'folder'}, {name:'照片.zip',type:'zip'}, ]; var iconMap = { folder: '<svg viewBox="0 0 24 24" width="42" height="42"><path fill="#FFA000" d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"/></svg>', zip: '<svg viewBox="0 0 24 24" width="42" height="42"><path fill="#616161" d="M7 3H4v18h16V7l-3-3H7zm0 2v10h10V5H7zm2 2h6v2H9V7zm0 4h6v2H9v-2zm0 4h6v2H9v-2z"/></svg>', }; var grid = document.getElementById('grid'); var selector = document.getElementById('selector'); var cards = []; fake.forEach((f,i)=>{ var card = document.createElement('div'); card.className = 'card'; card.innerHTML = `<div class="thumb"></div> <div class="name" title="${f.name}">${f.name}</div> <input type="checkbox" class="check">`; card.querySelector('.thumb').innerHTML = iconMap[f.type] || iconMap.default; card.dataset.idx = i; grid.appendChild(card); cards.push(card); }); var startEl = null; var selectedSet = new Set(); function refreshUI(){ cards.forEach(c=>{ c.classList.toggle('selected',selectedSet.has(+c.dataset.idx)); c.querySelector('.check').checked = selectedSet.has(+c.dataset.idx); }); var n = selectedSet.size; document.getElementById('selectedInfo').textContent = `已选 ${n} 项`; document.getElementById('delBtn').disabled = !n; document.getElementById('downBtn').disabled = !n; } function toggle(idx){ if(selectedSet.has(idx)) selectedSet.delete(idx); else selectedSet.add(idx); refreshUI(); } var selecting = 0, sx=0, sy=0, ex=0, ey=0; grid.addEventListener('mousedown',e=>{ if(e.target.closest('.card') && e.target.tagName !== 'INPUT') return; selecting = 1; var rect = grid.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; selector.style.left = sx + 'px'; selector.style.top = sy + 'px'; selector.style.width = selector.style.height = '0px'; selector.style.display = 'block'; }); document.addEventListener('mousemove',e=>{ if(!selecting) return; ex = e.clientX; ey = e.clientY; var x = Math.min(sx,ex), y = Math.min(sy,ey), w = Math.abs(ex-sx), h = Math.abs(ey-sy); selector.style.left = x + 'px'; selector.style.top = y + 'px'; selector.style.width = w + 'px'; selector.style.height = h + 'px'; var r1 = selector.getBoundingClientRect(); cards.forEach(c=>{ var r2 = c.getBoundingClientRect(); var collide = !(r1.right<r2.left||r1.left>r2.right||r1.bottom<r2.top||r1.top>r2.bottom); var idx = +c.dataset.idx; if(collide) selectedSet.add(idx); else selectedSet.delete(idx); }); refreshUI(); }); document.addEventListener('mouseup',e=>{ if(selecting){ selecting = 0; selector.style.display = 'none'; } }); grid.addEventListener('click',e=>{ var card = e.target.closest('.card'); if(!card) return; var idx = +card.dataset.idx; if(e.target.tagName === 'INPUT') { toggle(idx); startEl = card; } else if(e.ctrlKey||e.metaKey){ toggle(idx); startEl = card; } else if(e.shiftKey && startEl){ var a = +startEl.dataset.idx, b = idx; var min = Math.min(a,b), max = Math.max(a,b); selectedSet.clear(); for(var i=min;i<=max;i++) selectedSet.add(i); refreshUI(); } else{ selectedSet.clear(); selectedSet.add(idx); startEl = card; refreshUI(); } }); function clearAll(){ selectedSet.clear(); refreshUI(); } function batch(action){ var arr = Array.from(selectedSet); if(!arr.length) return; if(action==='delete') alert('删除: ' + arr.map(i=>fake[i].name).join(', ')); if(action==='download') alert('下载: ' + arr.map(i=>fake[i].name).join(', ')); }</script></body></html>
阅读原文:原文链接
该文章在 2025/12/22 18:53:25 编辑过