带有拖放功能的看板

正如着陆页中所述,拖放 API 同时模拟了三种用例:在页面内拖动元素、将数据从页面拖出以及将数据拖入页面。本教程演示了第一种用例:在页面内拖动元素。我们将实现一个看板应用程序,类似于GitHub ProjectsTrello提供的功能。

基本页面布局

由于我们主要在此演示拖放和重新排序,因此我们将省略真实看板应用程序的一些动态方面,例如添加和删除任务。相反,我们所有的列和任务都将被硬编码在 HTML 中。

html
<div class="container">
  <div class="task-column">
    <h2>To Do</h2>
    <ul class="tasks">
      <li class="task" draggable="true">Find out where Soul Stone is</li>
    </ul>
  </div>
  <div class="task-column">
    <h2>In Progress</h2>
    <ul class="tasks">
      <li class="task" draggable="true">Collect Time Stone from Dr. Strange</li>
      <li class="task" draggable="true">Collect Mind Stone from Vision</li>
      <li class="task" draggable="true">
        Collect Reality Stone from the Collector
      </li>
    </ul>
  </div>
  <div class="task-column">
    <h2>Done</h2>
    <ul class="tasks">
      <li class="task" draggable="true">Collect Power Stone from Xandar</li>
      <li class="task" draggable="true">Collect Space Stone from Asgard</li>
    </ul>
  </div>
</div>
css
body {
  font-family: "Arial", sans-serif;
}

.container {
  display: flex;
  gap: 0.5rem;
}

.task-column {
  border: 1px solid #cccccc;
  border-radius: 5px;
  margin: 10px;
  padding: 10px;
  flex: 1;
}

.tasks {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  list-style: none;
  padding: 0;
}

.task-column h2 {
  text-align: center;
}

.task {
  background-color: #f9f9f9;
  border: 1px solid #eeeeee;
  border-radius: 3px;
  padding: 8px;
  cursor: grab;
}

.task:active {
  cursor: grabbing;
}

@media (width < 600px) {
  .container {
    flex-direction: column;
  }
}

这定义了我们应用程序的基本结构和样式。每个任务都被设置为可拖动,但它们在被拖动时还不会做任何事情。

声明放置目标

我们希望将任务列变成有效的放置目标。作为基线,我们需要监听 dragover 事件并取消它。但是,我们会特别注意,仅在拖动的是任务时才取消事件 — 如果我们尝试放置其他任何东西,该列不应成为放置目标。

js
const columns = document.querySelectorAll(".task-column");

columns.forEach((column) => {
  column.addEventListener("dragover", (event) => {
    // Test a custom type we will set later
    if (event.dataTransfer.types.includes("task")) {
      event.preventDefault();
    }
  });
});

现在,当一个任务被拖到一列上方时,您可能会看到一个光标效果,例如一个加号,表示任务在放置时将被复制,因为复制是默认操作。稍后,我们将更改此指示器,因为任务实际上将被移动。

移动元素

现在我们实现了核心功能:在列之间移动任务的能力。它包括两个步骤:将拖动的元素添加到目标列并将其从源列中删除。

我们通过以下方式跟踪被拖动的元素和源列:在 dragstart 事件中,我们用一个 id 标记被拖动的任务。然后在 drop 事件中,我们可以使用此 ID 来识别任务并将其从源列中删除。最后,我们记得在 dragend 事件中删除 ID,以免在后续拖动中创建重复的 ID。

js
const tasks = document.querySelectorAll(".task");

tasks.forEach((task) => {
  task.addEventListener("dragstart", (event) => {
    task.id = "dragged-task";
    event.dataTransfer.effectAllowed = "move";
    // Custom type to identify a task drag
    event.dataTransfer.setData("task", "");
  });

  task.addEventListener("dragend", (event) => {
    task.removeAttribute("id");
  });
});

还有其他选项,例如为每个项目分配唯一的 ID,然后将此 ID 存储在 dataTransfer 中,或者将 DOM 元素的引用存储在全局变量中。所有这些方法的效果都大致相同。

因为任务始终应该被移动而不是复制或链接,所以我们还将 DataTransfer.effectAllowed 属性设置为 "move",以便它是唯一允许的效果。此更改会更新光标效果以指示移动操作。此外,我们还设置了一个类型为 taskdataTransfer 项,用于标识被拖动的任务,如前所述。

正如在放置效果中提到的,您只能在可拖动元素的 dragstart 处理程序中设置 effectAllowed

现在,我们可以在目标列的 drop 处理程序中实际触发移动操作。我们可以通过 ID 识别被拖动的任务,使用 Element.remove() 从 DOM 树中删除它,然后将其重新插入到目标列中。因为我们只允许在拖动的是任务时放置,所以我们可以确信 draggedTask 必定存在。

js
columns.forEach((column) => {
  column.addEventListener("drop", (event) => {
    event.preventDefault();

    const draggedTask = document.getElementById("dragged-task");
    draggedTask.remove();
    column.children[1].appendChild(draggedTask);
  });
});

此时,核心 UX 已经存在,您可以将任务在列之间拖动。

插入到特定位置

目前,无论我们将其放在何处,放置的任务总是插入到列的末尾。我们现在改进放置逻辑,使其插入到放置位置而不是末尾。但是,如何将放置位置映射到目标列的插入索引?这是一个需要权衡的决定,但我们将使用以下启发式方法(可以随意选择自己的):该项将被插入到光标悬停的项目所在的索引处。如果光标位于第一个项目上方或最后一个项目下方,它将分别插入到列的开头或结尾。如果光标位于两个项目之间,它将插入到光标下方的项目所在的索引处。

为了使放置位置明显,我们将为放置位置添加一个视觉指示器。这可以通过在放置位置插入一个占位符元素来实现,该占位符元素将在放置发生时被拖动的任务替换。首先定义占位符的创建函数

css
.placeholder {
  border: 1px solid #cccccc;
  border-radius: 3px;
}
js
function makePlaceholder(draggedTask) {
  const placeholder = document.createElement("li");
  placeholder.classList.add("placeholder");
  placeholder.style.height = `${draggedTask.offsetHeight}px`;
  return placeholder;
}

此指示器将在 dragover 事件中移动。这是所有内容中最复杂的,因此我们将其提取到一个单独的函数中。我们首先获取所需的元素

js
function movePlaceholder(event) {
  const column = event.currentTarget;
  const draggedTask = document.getElementById("dragged-task");
  const tasks = column.children[1];
  const existingPlaceholder = column.querySelector(".placeholder");

如果已经存在占位符,并且光标仍在其中,则无需更改任何内容。请注意,此时我们不会移除现有的占位符,因为这会改变页面的布局并可能导致闪烁。我们只在完全确定新位置后才更改布局。

js
if (existingPlaceholder) {
  const placeholderRect = existingPlaceholder.getBoundingClientRect();
  if (
    placeholderRect.top <= event.clientY &&
    placeholderRect.bottom >= event.clientY
  ) {
    return;
  }
}

否则,我们将搜索第一个未完全位于光标上方的任务。此任务可能是光标位于所有项目上方时的第一个任务,包含光标的任务,或光标位于两个项目之间时的光标下方的任务。我们的占位符应放置在此任务的位置。请注意,我们只比较 Y 坐标:即使光标位于左边距或右边距,也应将其视为位于任务上方。找到合适的插入点后,我们决定一些事情

  • 如果插入点已经是占位符,则无需更改任何内容。请注意,这与上面的条件并不完全相同:如果光标位于两个项目之间的占位符正上方,则此条件可能为 true。
  • 如果在放置时,被拖动的项目将放置在其原始位置,我们根本不应该指示占位符。这发生在占位符将要放置在被拖动任务旁边时,因此我们检查是插入到 draggedTask 之前(task === draggedTask)还是之后(task.previousElementSibling === draggedTask)。在这种情况下,我们仍然移除现有的占位符(如果存在)。
  • 最后,我们将占位符插入到确定的位置。
js
for (const task of tasks.children) {
  if (task.getBoundingClientRect().bottom >= event.clientY) {
    if (task === existingPlaceholder) return;
    existingPlaceholder?.remove();
    if (task === draggedTask || task.previousElementSibling === draggedTask)
      return;
    tasks.insertBefore(
      existingPlaceholder ?? makePlaceholder(draggedTask),
      task,
    );
    return;
  }
}

如果上面的循环没有找到合适的任务,则意味着所有现有任务都在光标上方,我们需要在末尾插入占位符。同样,如果被拖动的任务已经是最后一个项目,我们也不会添加占位符。

js
  existingPlaceholder?.remove();
  if (tasks.lastElementChild === draggedTask) return;
  tasks.append(existingPlaceholder ?? makePlaceholder(draggedTask));
}

最后,占位符会在 dragleavedrop 事件中被移除。请注意,当光标离开列进入其子元素时,会触发 dragleave 事件。因为我们只想在光标完全离开列时移除占位符,所以我们需要检查 relatedTarget(即我们正在进入的元素)是否是列的子元素。

drop 处理程序修改了我们在移动元素中实现的内容。而不是将任务附加到末尾,我们需要将其插入到中间,并利用占位符的位置来实现这一点。

js
columns.forEach((column) => {
  column.addEventListener("dragover", movePlaceholder);
  column.addEventListener("dragleave", (event) => {
    // If we are moving into a child element,
    // we aren't actually leaving the column
    if (column.contains(event.relatedTarget)) return;
    const placeholder = column.querySelector(".placeholder");
    placeholder?.remove();
  });
  column.addEventListener("drop", (event) => {
    event.preventDefault();

    const draggedTask = document.getElementById("dragged-task");
    const placeholder = column.querySelector(".placeholder");
    if (!placeholder) return;
    draggedTask.remove();
    column.children[1].insertBefore(draggedTask, placeholder);
    placeholder.remove();
  });
});

灰色显示原始任务

在拖动过程中,原始任务似乎仍在其位置。为了在视觉上表明任务正在被移动,我们可以应用“灰色显示”效果。通常也会将其从 DOM 中移除,但这可能会干扰我们设置的所有其他 DOM 测量逻辑,因此我们可以使用 CSS 来达到期望的效果。这很简单,因为我们已经为被拖动的任务提供了一个稳定的 ID。

css
#dragged-task {
  opacity: 0.2;
}

结果

另见