# HTML5 拖放操作

在有定位设备的可视化媒体上,drag 操作是 mousedown 事件的默认行为,随后是一系列 mousemove 事件,最后通过释放鼠标来触发 drop 操作。

# 创建可拖动的内容

在浏览器器中,文本选区、图像和链接默认是可拖动的,效果图:

拖动链接

拖动图片

拖动选区

给文本选区、图像和链接之外的对象设置 draggable="true" 该元素将变为可拖动的,下面 class='draggable' 的 div 元素是可拖动的:

<div class='container'>
    <div draggable="true" class='draggable'>可拖动的元素</div>
    <div class="dropTarget">拖到这里</div>
</div>

此时,你发现可以拖动 draggable="true" 的 div 元素,但是松开鼠标不会发生其他事情,要添加拖放功能,还需要使用 JavaScript API。

# 与拖放相关的事件

整个拖放过程涉及如下这些事件:

  • dragstart
  • drag
  • dragenter
  • dragleave
  • dragover
  • drop
  • dragend

在拖放流程中,有一个起点,它是 drag 产生的地方,对应的元素被称为 Source node,比如链接、图片、div 等,还有一个 drop target,它是捕获拖放的区域,不是所有的元素都能作为 drop target,比如图片就不能,另外,还有 data payload,是 DataTransfer 对象。

# dragstart 和 dragend

dragstart 和 dragend 事件都在 Source node 上触发,dragstart 的默认行为是初始化拖放操作,dragend 没有默认行为,它们都能冒泡。

const draggableItems = container.querySelectorAll('.draggable')

draggableItems.forEach(item => {
    item.addEventListener('dragstart', (e) => {
        e.target.style.opacity = '0.1'
        // e.preventDefault() // 取消事件,让元素不可拖动
    })

    item.addEventListener('dragend', (e) => {
        e.target.style.opacity = '1'
    })
})

上述代码在 dragstart 事件处理程序中,将被拖动的元素设置成半透明,在 dragend 事件处理程序中,将被拖动的元素设置成不透明。

# dragenter, dragover 和 dragleave

这三个事件在 drop target 上触发。dragenter 的默认行为是拒绝 immediate user selection 作为潜在的 drop target;dragover 的默认行为是将 event.dataTransfer.dropEffect 设置为 none;dragleave 没有默认行为。

const dropTargets = rootRef.current.querySelectorAll('.dropTarget')
dropTargets.forEach(dropTarget => {
    // 当一个可拖动的元素进入它时,它触发 dragenter
    dropTarget.addEventListener('dragenter', (e: DragEvent) => {
        const target = e.target as HTMLDivElement
        if (target) {
            target.style.borderColor = 'red'
        }

    })
    // 当一个可拖动的元素离开它时,它触发 dragleave
    dropTarget.addEventListener('dragleave', (e: DragEvent) => {
        const target = e.target as HTMLDivElement
        if (target) {
            target.style.borderColor = '#666'
        }
    })

    // dragover 事件的默认行为是将 event.dataTransfer.dropEffect 设置为 none,如此 drop 事件将不在它上面触发
    dropTarget.addEventListener('dragover', (e: DragEvent) => {
        e.preventDefault()
    })
})

# drop

给 drop target 绑定 drop 事件,在它的事件处理程序中完成最终的拖放操作,释放鼠标时要想 drop target 触发 drop 事件,必须取消 dragover 事件的默认行为。

dropTarget.addEventListener('drop', (e: DragEvent) => {
    // 让 drop 不往上冒泡,如果冒泡到 document,浏览器可能有一些奇怪的重定向。
    e.stopPropagation()
    // do something
})

# 与拖放相关的事件总结

事件名 target 是否能取消 是否能冒泡 drag data store 的 模式 默认行为
dragstart Source node 可读/可写 初始化拖拽操作
dragenter drop target 受保护的 拒绝 immediate user selection 作为潜在的 drop target
dragover drop target 受保护的 将 event.dataTransfer.dropEffect 设置为 none
dragleave drop target | - 受保护的
dragend Source node | - 受保护的 Varies
drop drop target 只读 Varies

在不同的事件中 drag data store 的模式不同,这是为了保证拖放操作的安全,因为如果用户将敏感信息从一个文档拖到另一个文档,在这个过程中穿过第三个恶意文档,这个恶意文档可能会篡改数据。

# DataTransfer 对象

它通过 dragEvent.dataTransfer 访问,接口如下:

interface DataTransfer {
    dropEffect: string;
    effectAllowed: string;
    readonly items: DataTransferItemList;
    readonly types: FrozenArray<string>;
    readonly files: FileList;
    
    setDragImage(image: Element, x: long, y: long) => undefined ;
    getData(format: string) => string;
    setData(format: string, data: string) => undefined;
    clearData(format?: string) => undefined;
}

# dropEffect

可能的取值有 "none", "copy", "link" 和 "move",它用于控制拖拽过程中鼠标手势,主要在 dragenter 和 dragover 事件中设置。

copy

link

move

# effectAllowed

可选值有 none、move、copy、copyLink、copyMove、linkMove、all、uninitialized。它指定拖放操作允许的效果,应该在 dragstart 事件中被设置成想要值。effectAllowed 与 dropEffect 的值有约束关系,如果我们设置 effectAllowed 值为 copyMove,那么 dropEffect 只有 copy 和 move 这两个值才有效。如果 effectAllowed 值和 dropEffect 值不匹配,drop target 则无法响应 drop 事件。

# setDragImage

setDragImage 方法用于自定义拖拽过程中的 feedback,代码如下:

function onDragStart(event) {
 const target = e.target
  if (target) {
    target.style.opacity = '0.1'
   }

 if (event.dataTransfer) {
    e.dataTransfer.setDragImage(dragFallBackElement, 20,20)
 }
}

得到的效果图如下:

# setData, getData 和 clearData

在 dragstart 中用 setData 为拖动操作设置特定的数据,用 clearData 移除特定的数据,如果不传参数将移除所有的数据,在 drop 中用 getData 方法访问特定的数据,如果没有相应的数据则返回空字符串。代码如下:

function onDragStart(e) {
 if (e.dataTransfer) {
    // e.dataTransfer.clearData()
    e.dataTransfer.setData('text/html', e.target.innerHTML)
    e.dataTransfer.setData('id',e.target.id)
 }
}

function onDrop(e) {
 e.stopPropagation()
 if (e.dataTransfer && target) {
    target.innerHTML = e.dataTransfer.getData('text/html')
    console.log(e.dataTransfer.getData('id'))
 }
}

# files

返回正在拖动的文件。

# items

它被称为 drag data store,用于保存在拖放操作期间需携带的数据。用 setData 添加的数据,除了通过 getData 访问还能通过 items 访问,files 字段保存的文件用 items 也能访问,items 的类型为 DataTransferItemList ,接口如下:

interface DataTransferItemList {
    readonly length: number;
    add(data: string, type: string): DataTransferItem | null;
    add(data: File): DataTransferItem | null;
    clear(): void;
    remove(index: number): void;
    [index: number]: DataTransferItem;
}

interface DataTransferItem {
    readonly kind: "string""file";
    readonly type: string;
    /** 如果 kind 是 'file',则返回 File */
    getAsFile(): File | null;
   
    /** 如果 kind 是 'string',在 callback 中将得到实际的数据 */
    getAsString(callback: FunctionStringCallback | null): void;
    webkitGetAsEntry(): FileSystemEntry | null;
}