拖放操作

拖放 API 的核心是各种拖动事件,它们以特定的顺序触发,并应以特定的方式处理。本文档描述了拖放操作期间发生的步骤,以及应用程序在每个处理程序中应执行的操作。

从宏观层面看,拖放操作可能包含以下步骤:

  • 用户在源节点上开始拖动dragstart 事件在源节点上触发。在此事件中,源节点为拖动操作准备上下文,包括拖动数据、反馈图像和允许的放置效果。
  • 用户拖动项目:每次进入新元素时,dragenter 事件在该元素上触发,而 dragleave 事件在之前的元素上触发。每隔几百毫秒,dragover 事件在当前拖动所在的元素上触发,而 drag 事件在源节点上触发。
  • 拖动进入有效的放置目标:放置目标取消其 dragover 事件以表明它是一个有效的放置目标。某种形式的放置反馈向用户指示预期的放置效果。
  • 用户执行放置drop 事件在放置目标上触发。在此事件中,目标节点读取拖动数据。
  • 拖动操作结束dragend 事件在源节点上触发。无论放置是否成功,此事件都会触发。

开始拖动

拖动从一个可拖动项开始,该项可以是选区、可拖动元素(包括链接、图片以及任何带有 draggable="true" 的元素)、来自操作系统文件管理器的文件等。首先,dragstart 事件在源节点上触发,源节点是可拖动元素,或者对于选区而言,是拖动开始的文本节点。如果此事件被取消,则拖动操作将中止。否则,pointercancel 事件也会在源节点上触发。

dragstart 事件是唯一可以修改 dataTransfer 的时机。对于自定义可拖动元素,您几乎总是需要修改拖动数据,这在修改拖动数据存储中有详细介绍。您还可以更改另外两件事:反馈图像允许的放置效果

在此示例中,我们使用 addEventListener() 方法添加了一个dragstart 事件的监听器。

html
<p draggable="true">This text <strong>may</strong> be dragged.</p>
js
const draggableElement = document.querySelector('p[draggable="true"]');
draggableElement.addEventListener("dragstart", (event) => {
  event.dataTransfer.setData("text/plain", "This text may be dragged");
});

您也可以监听更高层的祖先元素,因为拖动事件像大多数其他事件一样会冒泡。因此,通常还会检查事件的目标,这样在包含此元素的选区内拖动就不会触发 setData(尽管在元素内选择文本很困难,但并非不可能)

js
draggableElement.addEventListener("dragstart", (event) => {
  if (event.target === draggableElement) {
    event.dataTransfer.setData("text/plain", "This text may be dragged");
  }
});

设置拖动反馈图像

当发生拖动时,会从源节点生成一个半透明图像,并在拖动过程中跟随用户的指针。此图像是自动创建的,因此您无需自行创建。但是,您可以使用 setDragImage() 指定自定义拖动反馈图像。

js
draggableElement.addEventListener("dragstart", (event) => {
  event.dataTransfer.setDragImage(image, xOffset, yOffset);
});

需要三个参数。第一个是对图像的引用。此引用通常是 <img> 元素,但也可以是 <canvas> 或任何其他元素。反馈图像将根据图像在屏幕上的外观生成,尽管对于图像,它们将以其原始大小绘制。setDragImage() 方法的第二和第三个参数是图像相对于鼠标指针应出现的位置的偏移量。

您也可以使用文档中不存在的图像和画布。这种技术在绘制自定义拖动图像时使用 canvas 元素非常有用,如以下示例所示

js
draggableElement.addEventListener("dragstart", (event) => {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 50;

  const ctx = canvas.getContext("2d");
  ctx.lineWidth = 4;
  ctx.moveTo(0, 0);
  ctx.lineTo(50, 50);
  ctx.moveTo(0, 50);
  ctx.lineTo(50, 0);
  ctx.stroke();

  event.dataTransfer.setDragImage(canvas, 25, 25);
});

在此示例中,我们使一个画布成为拖动图像。由于画布是 50x50 像素,我们使用其一半的偏移量(25),使图像显示在鼠标指针的中心。

在元素上拖动并指定放置目标

在整个拖动操作过程中,所有设备输入事件(如鼠标或键盘)都会被抑制。拖动的数据可以在文档中的各个元素上移动,甚至可以在其他文档中的元素上移动。每当进入一个新元素时,dragenter 事件就会在该元素上触发,而 dragleave 事件则会在前一个元素上触发。

注意: dragleave 总是 dragenter 之后触发,因此从概念上讲,在这两个事件之间,目标已经进入了一个新元素但尚未离开前一个元素。

每隔几百毫秒,就会触发两个事件:源节点上的 drag 事件,以及拖动当前所在元素上的 dragover 事件。网页或应用程序的大部分区域都不是有效的数据放置位置,因此元素默认会忽略发生在它上面的任何放置操作。元素可以通过取消 dragover 事件来将自身选为有效的放置目标。如果该元素是一个可编辑的文本字段,例如 <textarea><input type="text">,并且数据存储包含一个 text/plain 项,那么该元素默认是一个有效的放置目标,无需取消 dragover

html
<div id="drop-target">You can drag and then drop a draggable item here</div>
js
const dropElement = document.getElementById("drop-target");

dropElement.addEventListener("dragover", (event) => {
  event.preventDefault();
});

注意: 规范要求放置目标也取消 dragenter 事件,否则 dragoverdragleave 事件甚至不会在该元素上触发;但在实践中,没有浏览器实现这一点,“当前元素”在每次进入新元素时都会更改。

注意: 规范要求取消 drag 事件会中止拖动;在实践中,没有浏览器实现这一点。请参见下面的示例。

条件放置目标

您通常只希望放置目标在某些情况下接受放置(例如,仅当拖动的是链接时)。为此,请检查条件并在满足条件时才取消事件。例如,您可以检查拖动数据是否包含链接

js
dropElement.addEventListener("dragover", (event) => {
  const isLink = event.dataTransfer.types.includes("text/uri-list");
  if (isLink) {
    event.preventDefault();
  }
});

在此示例中,我们使用 includes 方法检查类型 text/uri-list 是否存在于类型列表中。如果存在,我们将取消事件,以便允许放置。如果拖动数据不包含链接,则事件不会被取消,并且在该位置无法发生放置。

放置反馈

现在用户正拖动到有效的放置目标。有几种方法可以向用户指示在该位置允许放置,以及如果发生放置可能会发生什么。通常,鼠标指针会根据 dropEffect 属性的值进行必要的更新。尽管确切的外观取决于用户的平台,但通常对于 copy,例如会出现一个加号图标,而当不允许放置时,则会出现一个“此处无法放置”图标。这种鼠标指针反馈在许多情况下就足够了。

放置效果

放置时,可能会执行以下几种操作

copy

放置后,数据将同时存在于源位置和目标位置。

move

数据将仅存在于目标位置,并将从源位置删除。

源位置和放置位置之间将创建某种形式的链接;源位置的数据只有一个实例。

none

什么也没发生;放置失败。

通过 dragenterdragover 事件,dropEffect 属性被初始化为用户请求的效果。用户可以通过按修改键修改所需效果。尽管使用的确切键因平台而异,但通常会使用 ShiftControl 键来在复制、移动和链接之间切换。鼠标指针将改变以指示所需的操作。例如,对于 copy,光标旁边可能会出现一个加号。

您可以在 dragenterdragover 事件期间修改 dropEffect 属性,例如,如果某个特定的放置目标仅支持某些操作。您可以修改 dropEffect 属性以覆盖用户效果,并强制执行特定的放置操作。

js
target.addEventListener("dragover", (event) => {
  event.dataTransfer.dropEffect = "move";
});

在此示例中,执行的是移动效果。

您可以使用值 none 来指示在该位置不允许放置。如果元素只是暂时不接受放置,您通常应该这样做;如果它不打算成为放置目标,您就不应该取消事件。

请注意,设置 dropEffect 仅指示此刻所需的效应;稍后的 dragover 调度可能会更改它。为了保持选择,您必须在每个 dragover 事件中设置它。此外,此效应仅具有信息性,最终实现的效果取决于源节点和目标节点(例如,如果源节点无法修改,那么即使请求了“移动”效应,也可能无法实现)。

对于用户手势和编程设置 dropEffect,默认情况下,所有三种放置效果都可用。可拖动元素可以通过在 dragstart 事件监听器中设置 effectAllowed 属性来限制只允许某些效果。

js
draggableElement.addEventListener("dragstart", (event) => {
  event.dataTransfer.effectAllowed = "copyLink";
});

在此示例中,只允许复制或链接操作,而不能通过脚本或用户手势选择移动操作。

effectAllowed 的值是 dropEffect 的组合

描述
none 不允许任何操作
copy copy
move move
link link
copyMove copymove
copyLink copylink
linkMove linkmove
all copymovelink
未初始化 效果未设置时的默认值;通常等同于 all,除了默认的 dropEffect 可能不总是 copy

默认情况下,dropEffect 根据 effectAllowed 初始化,按 copylinkmove 的顺序,选择第一个允许的。如果合适,未选择但允许的效果也可能被选为默认值;例如,在 Windows 上,按住 Alt 键会导致优先使用 link。如果 effectAlloweduninitialized 且被拖动元素是 <a> 链接,则默认 dropEffectlink;如果 effectAlloweduninitialized 且被拖动元素是可编辑文本字段中的选区,则默认 dropEffectmove

自定义放置反馈

对于更复杂的视觉效果,您可以在 dragenter 事件期间执行其他操作,例如,在将发生放置的位置插入一个元素。这可能是一个插入标记或一个代表拖动元素在新位置的元素。为此,您可以创建一个 <img> 元素并在 dragenter 事件期间将其插入文档。

dragover 事件将在鼠标指向的元素上触发。自然地,您可能还需要在 dragover 事件处理程序中移动插入标记。您可以像其他鼠标事件一样使用事件的 clientXclientY 属性来确定鼠标指针的位置。

最后,当拖动离开元素时,dragleave 事件将在该元素上触发。这时您应该移除任何插入标记或高亮。您无需取消此事件。dragleave 事件总是会触发,即使拖动被取消,因此您始终可以确保在此事件期间完成任何插入点清理。

有关使用这些事件的实际示例,请参阅我们的 看板示例

执行放置

当用户松开鼠标时,拖放操作结束。

为了使放置可能成功,放置必须发生在有效的放置目标上,并且在鼠标松开时 dropEffect 不能是 none。否则,放置操作被认为是失败的。

如果放置可能成功,则在放置目标上会触发 drop 事件。您需要使用 preventDefault() 取消此事件,以便将放置视为实际成功。否则,如果放置是将文本(数据包含 text/plain 项)放入可编辑文本字段,则放置也被视为成功。在这种情况下,文本会被插入到字段中(根据平台惯例,插入到光标位置或末尾),并且如果 dropEffectmove 且源是可编辑区域内的选区,则会移除源。否则,对于所有其他拖动数据和放置目标,放置被视为失败。

drop 事件期间,您应该使用 DataTransfer.getData() 从拖动数据存储中检索所需数据,并将其插入到放置位置。您可以使用 dropEffect 属性来确定所需的拖动操作。drop 事件是除了 dragstart 之外唯一可以读取拖动数据存储的时间。

js
target.addEventListener("drop", (event) => {
  event.preventDefault();
  const data = event.dataTransfer.getData("text/plain");
  target.textContent = data;
});

在此示例中,一旦数据被检索,我们将其字符串作为目标的文本内容插入。这样做会将拖动文本插入到其放置的位置,假设放置目标是文本区域,如 pdiv 元素。

如果数据存储不包含指定类型的数据,getData() 方法将返回空字符串。如果您实现了条件放置目标,这种情况应该不会发生,因为放置目标只在所需数据存在时才接受放置。

您还可以检索其他类型的数据。如果数据是链接,其类型应为 text/uri-list。然后您可以将链接插入到内容中。

js
target.addEventListener("drop", (event) => {
  event.preventDefault();
  const lines = event.dataTransfer.getData("text/uri-list").split("\r\n");
  lines
    .filter((line) => !line.startsWith("#"))
    .forEach((line) => {
      const link = document.createElement("a");
      link.href = line;
      link.textContent = line;
      target.appendChild(link);
    });
});

有关如何读取拖动数据的更多信息,请参阅处理拖动数据存储

源元素和目标元素也有责任协作实现 dropEffect——源监听 dragend 事件,目标监听 drop 事件。例如,如果 dropEffectmove,那么其中一个元素必须从其旧位置移除拖动的项(通常是源元素本身,因为目标元素不一定知道或控制源)。

放置失败

如果以下任一情况为真,则拖放操作被视为失败:

  1. 用户按下了 Escape
  2. 放置发生在有效的放置目标之外
  3. 在鼠标松开时,放置效果为 none
  4. drop 事件未取消,并且放置操作不是将文本(包含 text/plain 数据)放入可编辑文本字段(参见执行放置

对于情况 1 和 3,如果中止发生在悬停在有效放置目标上方时,放置目标会收到一个 dragleave 事件,就像放置不再发生在其上方一样,以便它可以清除任何放置反馈。在所有情况下,后续事件的 dropEffect 都设置为 none

随后,在源节点上会触发一个 dragend 事件。浏览器可能会显示一个动画,将拖动的选区返回到拖放操作的源。

结束拖动

拖动完成后,dragend 事件将在拖动源(收到 dragstart 事件的同一元素)上触发。无论拖动是否成功,此事件都会触发。

如果在 dragend 期间 dropEffect 属性的值为 none,则拖动已取消。否则,该效果指定执行了哪个操作。源可以在 move 操作后使用此信息从旧位置移除拖动的项目。

放置可以发生在同一个窗口内或另一个应用程序上。dragend 事件始终会触发。事件的 screenXscreenY 属性将设置为放置发生时的屏幕坐标。

dragend 事件传播完成后,拖放操作完成。

另见