那些被忽略但很好用的 Web API / 拖拉式待办清单

就算拖拖拉拉,也可以把待办事项处理好

昨天虽然已经知道该如何使用 Drag & Drop API 了,不过今天会实际用它来做个「拖拉式待办清单」,用具体的范例来让我们更加理解它的运用技巧。


设计概念

# 确立需求与功能

  1. 既然是待办清单,那自然要可以新增待办事项
  2. 每项任务可以透过拖曳来进行状态的更换(待处理、进行中、已完成)
  3. 每项任务可以透过拖曳来进行排序的调换
  4. 每项任务可以透过拖曳来进行删除

开始实践

由於主要是为了示范 Drag & Drop,所以就不额外使用前端框架,并且也不进行资料的处理,完全使用 DOM 的增删操作来完成作品,如果各位想要完善范例的话可以再自行采用更方便的技术。

首先准备好我们 ToDo List 的结构和样式,大致上长成下面这样,样式部分各位可以各自发挥,这边就不秀出完整 CSS 了。

<div class="wrap">
  <div class="column">
    <div class="title todo">待处理</div>
    <div class="input-wrap">
      <input type="text" placeholder="+ 新增事项" />
      <button>新增</button>
    </div>
    <ol class="list"></ol>
  </div>
  <div class="column">
    <div class="title handle">进行中</div>
    <ol class="list"></ol>
  </div>
  <div class="column">
    <div class="title complete">已完成</div>
    <ol class="list"></ol>
  </div>
</div>
<div class="delete">删除</div>

https://ithelp.ithome.com.tw/upload/images/20211011/20125431O7l4TEkqMY.png

# 新增代办任务

首先我们先来透过 <input><button> 来完成「新增任务」的功能,透过点击按钮或按下 Enter 就会执行 createToDo 函式,用来创造一个 li 元素,并加上属性及文字後丢掉「待处理」的 ol 中。

const input = document.querySelector("input");
const button = document.querySelector("button");
const todoList = input.parentElement.nextElementSibling;

function createToDo(content) {
  const newItem = document.createElement("li");
  newItem.classList.add("item");
  // 记得要加上 draggable,这样任务才可以拖曳
  newItem.setAttribute("draggable", true);
  newItem.textContent = content;
  todoList.appendChild(newItem);
  input.value = "";
}

input.addEventListener("keydown", (e) => {
  if (!input.value.trim() || e.which !== 13) return;
  createToDo(input.value);
});

button.addEventListener("click", () => {
  if (!input.value.trim()) return;
  createToDo(input.value);
});

 

# 拖曳以改变任务状态

接着我们要让任务可以进行「拖曳」,且三个不同的区块都要可以「被放置」任务,也就是 Drag & Drop API 的部分了,分别把 Drag Source 和 Drop Location 监听事件的流程包装成函式,然後在新增任务时把元素加上 drag 相关事件,以及为三个状态区块加上 drop 相关事件。

// 用来暂存被 drag 的元素
let source = null;
function addDragEvt(element) {
  element.addEventListener("dragstart", (e) => {
    e.target.classList.add("dragging");
    source = e.target;
  });
  element.addEventListener("dragend", (e) => {
    e.target.classList.remove("dragging");
    source = null;
  });
}

function createToDo(content) {
  // ...前面省略
  // 记得在 createToDo 中加入这一行来为新增的 li 监听事件
  addDragEvt(newItem);
  todoList.appendChild(newItem);
  input.value = "";
}
function addDropEvt(element) {
  element.addEventListener("dragover", (e) => {
    e.preventDefault();
  });
  element.addEventListener("drop", (e) => {
    e.currentTarget.querySelector("ol").appendChild(source);
  });
}

const columns = document.querySelectorAll(".column");
columns.forEach((column) => {
  addDropEvt(column);
});

 

# 拖曳以改变任务排序

现在各项任务已经可以通过拖曳放置在不同状态的区块了,现在要来处理排序问题了,我们可以先透过 dragover 事件来取得鼠标的位置,得以判断使用者想要把项目放在哪一个位置,并且利用样式的改变让使用者能更清楚知道他放开滑鼠後,任务会被加在哪里:

.item {
  position: relative;
}
.item::before,
.item::after {
  content: "";
  position: absolute;
  display: block;
  width: 100%;
  height: 4px;
  background: lightblue;
  opacity: 0;
}
.item.before::before {
  top: -2px;
  left: 0;
  opacity: 1;
}
.item.after::after {
  bottom: -2px;
  left: 0;
  opacity: 1;
}
// 用来暂存被 dragover 的元素
let overItem = null;

// 重置被 dragover 的元素
function clearOverItem() {
  if (!overItem) return;
  overItem.classList.remove("before");
  overItem.classList.remove("after");
  overItem = null;
}

function addDropEvt(element) {
  element.addEventListener("dragover", (e) => {
    clearOverItem();
    // 如果 dragover 的元素也是任务项目且不是目前被 drag 的 source 时执行
    if (e.target.getAttribute("draggable") && e.target !== source) {
      overItem = e.target;

      if (e.offsetY > overItem.offsetHeight / 2) {
        // 如果鼠标在元素的下半部显示下方的蓝条
        overItem.classList.add("after");
      } else {
        // 反之,显示上方的蓝条
        overItem.classList.add("before");
      }
    }
    e.preventDefault();
  });
  //...以下省略
}

接着我们只要在修改一下 drop 事件,在当中判断目前被 dragover 元素的状态就可以放到对应的位置了:

function addDropEvt(element) {
  //...以上省略
  element.addEventListener("drop", (e) => {
    const list = e.currentTarget.querySelector("ol");
    if (overItem) {
      if (overItem.classList.contains("before")) {
        // 如果 overItem 有 before class 就将 source 移动到它的前面
        list.insertBefore(source, overItem);
      } else {
        // 反之,有 after class 就将 source 移动到它的後面
        list.insertBefore(source, overItem.nextElementSibling);
      }
    } else {
      // 如果没有 overItem 也没有更换状态就不动作
      if (e.currentTarget.contains(source)) return;
      // 反之,加到最後面
      else list.appendChild(source);
    }
    clearOverItem();
  });
}

 

# 拖曳以删除任务

最後在把删除的功能给补上,这样一切就大功告成了。

const del = document.querySelector(".delete");
del.addEventListener("dragover", (e) => {
  e.preventDefault();
});
del.addEventListener("drop", (e) => {
  source.remove();
  clearOverItem();
});

 

整个范例做完後,希望各位对於 Drag & Drop API 能有更深更具体的认识,如果你在动手做之前想先试玩看看的话,我把原始码放在 CodePen 罗,如果文章中的说明看不是很懂的话,也可以在 CodePen 看看,有任何问题或建议也好欢迎各位提出~


<<:  【DAY 27】Microsoft 365 X Dynamic 365该怎麽选才好呢? (上)

>>:  Day26-实作(列表区) (part1)

LINE出现网路错误无法连线的其一处理方法

今日远端帮客户处理LINE连线不上问题, 很奇妙,上网都正常,但就是一直显示网路错误, 登入後有跳验...

【程序】陷入低潮 转生成恶役菜鸟工程师避免 Bad End 的 30 件事 - 23

https://youtu.be/vpwC347cXog 陷入低潮 了解低潮 专注在可控的短期 充...

电子书阅读器上的浏览器 [Day21] 翻译功能 (III) Google Translate

双开 WebView 并开启 Google Translate 网页 先来看看今天想要完成的功能的样...

DAY9: setImmediate 与 nextTick的比较

继上一篇的DAY8: process.nextTick(),今天要介绍新方法并相互比较。 setIm...

如何设计SQL 表格来提升查询非过往历史资料的效能?

个人正在写一个场地租借系统, 提前开放2周给人预约, 租借的过期纪录要保留起来作系统或规则改善研究,...