使用 HTML + JavaScript 实现滑动框选功能(附完整代码)

来源:技术小丁
滑动框选是 Web 应用中常见的交互模式之一,广泛应用于操作系统资源管理器和各类在线文档管理系统中。通过简单的拖拽动作就能快速选择多个目标项,极大地提升了用户操作效率。本文将介绍如何使用 HTML、CSS 和 JavaScript 实现一个支持滑动框选功能的文件管理界面。

效果演示

用户可以在网格区域内自由拖拽形成一个矩形区域来选择多个卡片,支持传统点击选中/取消单个元素,可以配合 Ctrl 或 Shift 键进行多选操作,顶部工具栏实时显示当前选中数量,并启用相应操作按钮。
image-20251219233518900

页面结构

页面整体采用简洁清晰的设计风格,主要由三部分组成:头部操作栏、主体内容区和辅助选择框。

头部操作栏

头部包含标题、选中统计信息及批量操作按钮。这些按钮的状态会根据当前选中状态动态更新。
<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="grid"></div>

辅助选择框

这是一个绝对定位的透明层,仅在执行滑动框选时显示。其大小和位置完全跟随鼠标移动轨迹变化,用于视觉反馈和计算碰撞检测。
<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') {    // 点击 checkbox    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>      * {          margin0;          padding0;          box-sizing: border-box;          -webkit-user-select: none;          -moz-user-select: none;          -ms-user-select: none;          user-select: none;      }      htmlbody {          height100%;      }      body {          display: flex;          flex-direction: column;          background#f6f7f9;          color#333;      }      header {          height52px;          background#fff;          border-bottom1px solid #e5e5e5;          display: flex;          align-items: center;          padding0 24px;          position: sticky;          top0;          z-index9;          flex-shrink0;      }      header h3 {          font-size18px;          font-weight500;          margin-right: auto;      }      header .act {          margin-left12px;          padding6px 14px;          border1px solid #d0d0d0;          border-radius4px;          background#fff;          font-size14px;          cursor: pointer;      }      header .act.primary {          background#06a7ff;          color#fff;          border-color#06a7ff;      }      header .act:disabled {          opacity: .5;          cursor: not-allowed;      }      #selectedInfo {          margin-left12px;          font-size14px;          color#666;      }      #grid {          flex1;          display: grid;          grid-template-columnsrepeat(auto-fill, 118px);          grid-auto-rows148px;          gap14px;          padding24px;          overflow-y: auto;      }      .card {          position: relative;          width118px;          height148px;          background#fff;          border1px solid #e5e5e5;          border-radius8px;          cursor: pointer;          transition: transform .15s, box-shadow .15s;          overflow: hidden;      }      .card:hover {          box-shadow0 4px 12px rgba(000, .08);      }      .card .thumb {          height88px;          display: flex;          align-items: center;          justify-content: center;          font-size42px;          color#999;      }      .card .name {          font-size13px;          padding0 8px;          text-align: center;          white-space: nowrap;          text-overflow: ellipsis;          overflow: hidden;          line-height20px;      }      .card .check {          position: absolute;          top6px;          left6px;          width18px;          height18px;          cursor: pointer;          z-index1;      }      .card.selected {          border-color#06a7ff !important;      }      #selector {          position: absolute;          border1px dashed #06a7ff;          backgroundrgba(6167255, .08);          display: none;          pointer-events: none;          z-index8;      }  </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';    }  });  // 支持 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') {      // 点击 checkbox      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.lengthreturn;    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>
THE END