帶有拖放功能的看板
正如著陸頁中所述,拖放 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;
}