带有拖放功能的看板
正如着陆页中所述,拖放 API 同时模拟了三种用例:在页面内拖动元素、将数据从页面拖出以及将数据拖入页面。本教程演示了第一种用例:在页面内拖动元素。我们将实现一个看板应用程序,类似于GitHub Projects或Trello提供的功能。
基本页面布局
由于我们主要在此演示拖放和重新排序,因此我们将省略真实看板应用程序的一些动态方面,例如添加和删除任务。相反,我们所有的列和任务都将被硬编码在 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>
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
事件并取消它。但是,我们会特别注意,仅在拖动的是任务时才取消事件 — 如果我们尝试放置其他任何东西,该列不应成为放置目标。
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。
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"
,以便它是唯一允许的效果。此更改会更新光标效果以指示移动操作。此外,我们还设置了一个类型为 task
的 dataTransfer
项,用于标识被拖动的任务,如前所述。
正如在放置效果中提到的,您只能在可拖动元素的 dragstart
处理程序中设置 effectAllowed
。
现在,我们可以在目标列的 drop
处理程序中实际触发移动操作。我们可以通过 ID 识别被拖动的任务,使用 Element.remove()
从 DOM 树中删除它,然后将其重新插入到目标列中。因为我们只允许在拖动的是任务时放置,所以我们可以确信 draggedTask
必定存在。
columns.forEach((column) => {
column.addEventListener("drop", (event) => {
event.preventDefault();
const draggedTask = document.getElementById("dragged-task");
draggedTask.remove();
column.children[1].appendChild(draggedTask);
});
});
此时,核心 UX 已经存在,您可以将任务在列之间拖动。
插入到特定位置
目前,无论我们将其放在何处,放置的任务总是插入到列的末尾。我们现在改进放置逻辑,使其插入到放置位置而不是末尾。但是,如何将放置位置映射到目标列的插入索引?这是一个需要权衡的决定,但我们将使用以下启发式方法(可以随意选择自己的):该项将被插入到光标悬停的项目所在的索引处。如果光标位于第一个项目上方或最后一个项目下方,它将分别插入到列的开头或结尾。如果光标位于两个项目之间,它将插入到光标下方的项目所在的索引处。
为了使放置位置明显,我们将为放置位置添加一个视觉指示器。这可以通过在放置位置插入一个占位符元素来实现,该占位符元素将在放置发生时被拖动的任务替换。首先定义占位符的创建函数
.placeholder {
border: 1px solid #cccccc;
border-radius: 3px;
}
function makePlaceholder(draggedTask) {
const placeholder = document.createElement("li");
placeholder.classList.add("placeholder");
placeholder.style.height = `${draggedTask.offsetHeight}px`;
return placeholder;
}
此指示器将在 dragover
事件中移动。这是所有内容中最复杂的,因此我们将其提取到一个单独的函数中。我们首先获取所需的元素
function movePlaceholder(event) {
const column = event.currentTarget;
const draggedTask = document.getElementById("dragged-task");
const tasks = column.children[1];
const existingPlaceholder = column.querySelector(".placeholder");
如果已经存在占位符,并且光标仍在其中,则无需更改任何内容。请注意,此时我们不会移除现有的占位符,因为这会改变页面的布局并可能导致闪烁。我们只在完全确定新位置后才更改布局。
if (existingPlaceholder) {
const placeholderRect = existingPlaceholder.getBoundingClientRect();
if (
placeholderRect.top <= event.clientY &&
placeholderRect.bottom >= event.clientY
) {
return;
}
}
否则,我们将搜索第一个未完全位于光标上方的任务。此任务可能是光标位于所有项目上方时的第一个任务,包含光标的任务,或光标位于两个项目之间时的光标下方的任务。我们的占位符应放置在此任务的位置。请注意,我们只比较 Y 坐标:即使光标位于左边距或右边距,也应将其视为位于任务上方。找到合适的插入点后,我们决定一些事情
- 如果插入点已经是占位符,则无需更改任何内容。请注意,这与上面的条件并不完全相同:如果光标位于两个项目之间的占位符正上方,则此条件可能为 true。
- 如果在放置时,被拖动的项目将放置在其原始位置,我们根本不应该指示占位符。这发生在占位符将要放置在被拖动任务旁边时,因此我们检查是插入到
draggedTask
之前(task === draggedTask
)还是之后(task.previousElementSibling === draggedTask
)。在这种情况下,我们仍然移除现有的占位符(如果存在)。 - 最后,我们将占位符插入到确定的位置。
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;
}
}
如果上面的循环没有找到合适的任务,则意味着所有现有任务都在光标上方,我们需要在末尾插入占位符。同样,如果被拖动的任务已经是最后一个项目,我们也不会添加占位符。
existingPlaceholder?.remove();
if (tasks.lastElementChild === draggedTask) return;
tasks.append(existingPlaceholder ?? makePlaceholder(draggedTask));
}
最后,占位符会在 dragleave
或 drop
事件中被移除。请注意,当光标离开列进入其子元素时,会触发 dragleave
事件。因为我们只想在光标完全离开列时移除占位符,所以我们需要检查 relatedTarget
(即我们正在进入的元素)是否是列的子元素。
drop
处理程序修改了我们在移动元素中实现的内容。而不是将任务附加到末尾,我们需要将其插入到中间,并利用占位符的位置来实现这一点。
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。
#dragged-task {
opacity: 0.2;
}