帶有拖放功能的看板

正如著陸頁中所述,拖放 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;
}

結果

另見