如何像 Gmail 一样检测进入和离开窗口的 HTML5 拖动事件?

Posted

技术标签:

【中文标题】如何像 Gmail 一样检测进入和离开窗口的 HTML5 拖动事件?【英文标题】:How do I detect a HTML5 drag event entering and leaving the window, like Gmail does? 【发布时间】:2011-03-09 20:41:41 【问题描述】:

我希望能够在带有文件的光标进入浏览器窗口时突出显示放置区域,这与 Gmail 的做法完全一样。但我无法让它发挥作用,我觉得我只是错过了一些非常明显的东西。

我一直在尝试做这样的事情:

this.body = $('body').get(0)
this.body.addEventListener("dragenter", this.dragenter, true)
this.body.addEventListener("dragleave", this.dragleave, true)`

但是,只要光标移出和移出 BODY 以外的元素,就会触发事件,这是有道理的,但绝对行不通。我可以在所有东西上放置一个元素,覆盖整个窗口并对其进行检测,但这将是一种可怕的方式。

我错过了什么?

【问题讨论】:

除了下面的答案:我注意到至少在 chrome 下,事件的顺序是: ENTER ENTER LEAVE ENTER LEAVE ... LEAVE 这意味着如果你保持进入和离开的数量,你将能够区分初始进入和内部进入/离开序列 PS:抱歉,格式化受够了...... 你就是那个人@MartinWawrusch!谢谢你 @MartinWawrusch 几乎完美。如果页面本身内部发生“丢弃”,则不会触发最后一次离开。因此,为了完整起见,ENTER [ENTER LEAVE]* [LEAVE|DROP] 是正确的顺序。应该使用捕获(将第三个参数设置为true)而不是在 html/document/body 元素上注册事件在window 上,这样如果其他人在某事上调用e.stopPropagation(),则不会错过任何事件在树的更深处。 【参考方案1】:

我用超时解决了它(不是很干净,但有效):

var dropTarget = $('.dropTarget'),
    html = $('html'),
    showDrag = false,
    timeout = -1;

html.bind('dragenter', function () 
    dropTarget.addClass('dragging');
    showDrag = true; 
);
html.bind('dragover', function()
    showDrag = true; 
);
html.bind('dragleave', function (e) 
    showDrag = false; 
    clearTimeout( timeout );
    timeout = setTimeout( function()
        if( !showDrag ) dropTarget.removeClass('dragging'); 
    , 200 );
);

我的示例使用 jQuery,但这不是必需的。以下是正在发生的事情的摘要:

在 html(或正文)元素的 dragenterdragover 上将标志 (showDrag) 设置为 true。 在dragleave 上将标志设置为false。然后设置一个短暂的超时来检查标志是否仍然为假。 理想情况下,在设置下一个超时之前跟踪并清除它。

这样,每个dragleave 事件都会给DOM 足够的时间让新的dragover 事件重置标志。我们关心的真实的、最终的 dragleave 会看到标志仍然是假的。

【讨论】:

将此答案与 Martin Wawrusch 对该问题的评论相结合,我得到了最好的结果。每次处理任何事件时都会重置计时器,并在拖动离开相等的拖动者时完全取消。这给了我在 webkit 浏览器中的即时反馈,并给了我在 firefox 中短暂滞后的反馈,它在离开窗口时不能可靠地调用 dragleave。 换句话说,您去抖动 dragleave 事件的处理程序。聪明的解决方法。【参考方案2】:

Rehmat 的修改版(谢谢)

我喜欢这个想法,而不是写一个新的答案,我在这里自己更新它。通过检查窗口尺寸可以使其更精确。


var body = document.querySelector("body");
body.ondragleave = (e) => 
  if (
    e.clientX >= 0 && e.clientX <= body.clientWidth
    && e.clientY >= 0 && e.clientY <= body.clientHeight
  )  else 
    // do something here
  



旧版本

不知道这对所有情况都有效,但在我的情况下效果很好

$('body').bind("dragleave", function(e) 
   if (!e.originalEvent.clientX && !e.originalEvent.clientY) 
          //outside body / window
   
);

【讨论】:

这并不总能成功检测到拖动的结束(例如:从浏览器外部拖动并放在 chrome 中的“下载”栏上的文件不会发送带有 0,0 作为客户X/Y 这可以向上、向下和向左工作。如果有滚动条,右键会失败。【参考方案3】:

将事件添加到document 似乎有效?通过 Chrome、Firefox、IE 10 测试。

获得事件的第一个元素是&lt;html&gt;,我认为应该没问题。

var dragCount = 0,
    dropzone = document.getElementById('dropzone');

function dragenterDragleave(e) 
  e.preventDefault();
  dragCount += (e.type === "dragenter" ? 1 : -1);
  if (dragCount === 1) 
    dropzone.classList.add('drag-highlight');
   else if (dragCount === 0) 
    dropzone.classList.remove('drag-highlight');
  
;

document.addEventListener("dragenter", dragenterDragleave);
document.addEventListener("dragleave", dragenterDragleave);

【讨论】:

【参考方案4】:

@tyler 的回答是最好的!我赞成它。花了这么多小时后,我的建议完全按照预期工作。

$(document).on('dragstart dragenter dragover', function(event)     
    // Only file drag-n-drops allowed, http://jsfiddle.net/guYWx/16/
    if ($.inArray('Files', event.originalEvent.dataTransfer.types) > -1) 
        // Needed to allow effectAllowed, dropEffect to take effect
        event.stopPropagation();
        // Needed to allow effectAllowed, dropEffect to take effect
        event.preventDefault();

        $('.dropzone').addClass('dropzone-hilight').show();     // Hilight the drop zone
        dropZoneVisible= true;

        // http://www.html5rocks.com/en/tutorials/dnd/basics/
        // http://api.jquery.com/category/events/event-object/
        event.originalEvent.dataTransfer.effectAllowed= 'none';
        event.originalEvent.dataTransfer.dropEffect= 'none';

         // .dropzone .message
        if($(event.target).hasClass('dropzone') || $(event.target).hasClass('message')) 
            event.originalEvent.dataTransfer.effectAllowed= 'copyMove';
            event.originalEvent.dataTransfer.dropEffect= 'move';
         
    
).on('drop dragleave dragend', function (event)   
    dropZoneVisible= false;

    clearTimeout(dropZoneTimer);
    dropZoneTimer= setTimeout( function()
        if( !dropZoneVisible ) 
            $('.dropzone').hide().removeClass('dropzone-hilight'); 
        
    , dropZoneHideDelay); // dropZoneHideDelay= 70, but anything above 50 is better
);

【讨论】:

【参考方案5】:

这是另一个解决方案。我是用 React 写的,但如果你想用纯 JS 重建它,我会在最后解释它。它与此处的其他答案相似,但可能更精致一些。

import React from 'react';
import styled from '@emotion/styled';
import BodyEnd from "./BodyEnd";

const DropTarget = styled.div`
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    pointer-events: none;
    background-color:rgba(0,0,0,.5);
`;

function addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions) 
    document.addEventListener(type, listener, options);
    return () => document.removeEventListener(type, listener, options);


function setImmediate(callback: (...args: any[]) => void, ...args: any[]) 
    let cancelled = false;
    Promise.resolve().then(() => cancelled || callback(...args));
    return () => 
        cancelled = true;
    ;


function noop()

function handleDragOver(ev: DragEvent) 
    ev.preventDefault();
    ev.dataTransfer!.dropEffect = 'copy';



export default class FileDrop extends React.Component 

    private listeners: Array<() => void> = [];

    state = 
        dragging: false,
    

    componentDidMount(): void 
        let count = 0;
        let cancelImmediate = noop;

        this.listeners = [
            addEventListener('dragover',handleDragOver),
            addEventListener('dragenter',ev => 
                ev.preventDefault();

                if(count === 0) 
                    this.setState(dragging: true)
                
                ++count;
            ),
            addEventListener('dragleave',ev => 
                ev.preventDefault();
                cancelImmediate = setImmediate(() => 
                    --count;
                    if(count === 0) 
                        this.setState(dragging: false)
                    
                )

            ),
            addEventListener('drop',ev => 
                ev.preventDefault();
                cancelImmediate();
                if(count > 0) 
                    count = 0;
                    this.setState(dragging: false)
                
            ),
        ]
    

    componentWillUnmount(): void 
        this.listeners.forEach(f => f());
    


    render() 
        return this.state.dragging ? <BodyEnd><DropTarget/></BodyEnd> : null;
    

因此,正如其他人所观察到的那样,dragleave 事件在下一个 dragenter 触发之前触发,这意味着当我们在页面上拖动文件(或其他任何内容)时,我们的计数器会瞬间变为 0。为了防止这种情况发生,我使用setImmediate 将事件推送到 javascript 事件队列的底部。

setImmediate 没有得到很好的支持,所以我编写了自己的版本,无论如何我更喜欢它。我还没有看到其他人像这样实现它。我使用Promise.resolve().then 将回调移动到下一个刻度。这比 setImmediate(..., 0) 更快,并且比我见过的许多其他 hack 更简单。

然后我做的另一个“技巧”是在您删除文件时清除/取消离开事件回调,以防万一我们有一个待处理的回调——这将防止计数器进入负数并搞砸一切。

就是这样。在我的初始测试中似乎工作得很好。没有延迟,我的放置目标没有闪烁。


ev.dataTransfer.items.length也可以获取文件数

【讨论】:

这是一个很好的答案,效果很好。我已经移植了用作 Vue 指令的代码:gist.github.com/Radiergummi/5b6f9a3c59b3c11130065443c77b4f0f【参考方案6】:

addEventListener 的第三个参数是true,它使侦听器在捕获阶段运行(请参阅http://www.w3.org/TR/DOM-Level-3-Events/#event-flow 以获得可视化效果)。这意味着它将捕获为其后代准备的事件 - 以及表示页面上所有元素的正文。在您的处理程序中,您必须检查触发它们的元素是否是主体本身。我会给你我非常肮脏的做法。如果有人知道实际比较元素的更简单方法,我很想看看。

this.dragenter = function() 
    if ($('body').not(this).length != 0) return;
    ... functional code ...

这会找到正文并从找到的元素集中删除this。如果集合不为空,this 就不是主体,所以我们不喜欢这个并返回。如果thisbody,则集合为空,代码执行。

您可以尝试使用简单的if (this == $('body').get(0)),但这可能会失败。

【讨论】:

【参考方案7】:

我自己遇到了这个问题,并想出了一个可用的解决方案,尽管我对必须使用叠加层并不感到疯狂。

ondragoverondragleaveondrop添加到窗口

ondragenterondragleaveondrop 添加到叠加层和目标元素

如果拖放发生在窗口或覆盖上,它会被忽略,而目标会根据需要处理拖放。我们需要叠加层的原因是因为ondragleave 会在每次元素悬停时触发,因此叠加层可以防止这种情况发生,而放置区被赋予更高的 z-index 以便可以放置文件。我正在使用在其他拖放相关问题中找到的一些代码 sn-ps,所以我不能完全相信。这是完整的 HTML:

<!DOCTYPE html>
<html>
    <head>
        <title>Drag and Drop Test</title>
        <meta http-equiv="X-UA-Compatible" content="chrome=1" />
        <style>
        #overlay 
            display: none;
            left: 0;
            position: absolute;
            top: 0;
            z-index: 100;
        
        #drop-zone 
            background-color: #e0e9f1;
            display: none;
            font-size: 2em;
            padding: 10px 0;
            position: relative;
            text-align: center;
            z-index: 150;
        
        #drop-zone.hover 
            background-color: #b1c9dd;
        
        output 
            bottom: 10px;
            left: 10px;
            position: absolute;
        
        </style>
        <script>
            var windowInitialized = false;
            var overlayInitialized = false;
            var dropZoneInitialized = false;

            function handleFileSelect(e) 
                e.preventDefault();

                var files = e.dataTransfer.files;
                var output = [];

                for (var i = 0; i < files.length; i++) 
                    output.push('<li>',
                        '<strong>', escape(files[i].name), '</strong> (', files[i].type || 'n/a', ') - ',
                        files[i].size, ' bytes, last modified: ',
                        files[i].lastModifiedDate ? files[i].lastModifiedDate.toLocaleDateString() : 'n/a',
                        '</li>');
                

                document.getElementById('list').innerHTML = '<ul>' + output.join('') + '</ul>';
            

            window.onload = function () 
                var overlay = document.getElementById('overlay');
                var dropZone = document.getElementById('drop-zone');

                dropZone.ondragenter = function () 
                    dropZoneInitialized = true;
                    dropZone.className = 'hover';
                ;
                dropZone.ondragleave = function () 
                    dropZoneInitialized = false;
                    dropZone.className = '';
                ;
                dropZone.ondrop = function (e) 
                    handleFileSelect(e);
                    dropZoneInitialized = false;
                    dropZone.className = '';
                ;

                overlay.style.width = (window.innerWidth || document.body.clientWidth) + 'px';
                overlay.style.height = (window.innerHeight || document.body.clientHeight) + 'px';
                overlay.ondragenter = function () 
                    if (overlayInitialized) 
                        return;
                    

                    overlayInitialized = true;
                ;
                overlay.ondragleave = function () 
                    if (!dropZoneInitialized) 
                        dropZone.style.display = 'none';
                    
                    overlayInitialized = false;
                ;
                overlay.ondrop = function (e) 
                    e.preventDefault();
                    dropZone.style.display = 'none';
                ;

                window.ondragover = function (e) 
                    e.preventDefault();

                    if (windowInitialized) 
                        return;
                    

                    windowInitialized = true;
                    overlay.style.display = 'block';
                    dropZone.style.display = 'block';
                ;
                window.ondragleave = function () 
                    if (!overlayInitialized && !dropZoneInitialized) 
                        windowInitialized = false;
                        overlay.style.display = 'none';
                        dropZone.style.display = 'none';
                    
                ;
                window.ondrop = function (e) 
                    e.preventDefault();

                    windowInitialized = false;
                    overlayInitialized = false;
                    dropZoneInitialized = false;

                    overlay.style.display = 'none';
                    dropZone.style.display = 'none';
                ;
            ;
        </script>
    </head>

    <body>
        <div id="overlay"></div>
        <div id="drop-zone">Drop files here</div>
        <output id="list"><output>
    </body>
</html>

【讨论】:

【参考方案8】:

我看到很多过度设计的解决方案。听听dragenterdragleave,你应该能够做到这一点,就像你的直觉告诉你的那样。

棘手的部分是,当dragleave 触发时,它的toElementfromElement 似乎与日常生活中的意义相反(这在逻辑上是有意义的,因为它是@ 的倒置动作987654326@)。 当您将光标从监听元素移动到该元素之外时,toElement 将具有监听元素,fromElement 将具有外部非监听元素。在我们的例子中,当我们拖出浏览器时,fromElement 将是null

解决方案

window.addEventListener("dragleave", function(e)
  if (!e.fromElement)
    console.log("Dragging back to OS")
  
)

window.addEventListener("dragenter", function(e)
  console.log("Dragging to browser")
)

【讨论】:

【参考方案9】:

ondragenter 经常被解雇。您可以避免使用像 draggedFile 这样的辅助变量。如果您不关心 on ondragenter 函数被调用的频率,您可以删除该辅助变量。

解决方案:

let draggedFile = false;

window.ondragenter = (e) => 
    if(!draggedFile) 
        draggedFile = true;
        console.log("dragenter");
    


window.ondragleave = (e) => 
    if (!e.fromElement && draggedFile) 
        draggedFile = false;
        console.log("dragleave");
    

【讨论】:

【参考方案10】:

您是否注意到 Gmail 中的拖放区会出现延迟?我的猜测是他们让它在一个计时器(约 500 毫秒)上消失,该计时器通过拖动或某些此类事件重置。

您描述的问题的核心是即使您拖动到子元素中也会触发dragleave。我正在尝试找到一种方法来检测这一点,但我还没有一个优雅干净的解决方案。

【讨论】:

【参考方案11】:

很抱歉发布特定于角度和下划线的内容,但是我解决问题的方式(HTML5 规范,适用于 chrome)应该很容易观察。

.directive('documentDragAndDropTrigger', function()
return
  controller: function($scope, $document)

    $scope.drag_and_drop = ;

    function set_document_drag_state(state)
      $scope.$apply(function()
        if(state)
          $document.context.body.classList.add("drag-over");
          $scope.drag_and_drop.external_dragging = true;
        
        else
          $document.context.body.classList.remove("drag-over");
          $scope.drag_and_drop.external_dragging = false;
        
      );
    

    var drag_enters = [];
    function reset_drag()
      drag_enters = [];
      set_document_drag_state(false);
    
    function drag_enters_push(event)
      var element = event.target;
      drag_enters.push(element);
      set_document_drag_state(true);
    
    function drag_leaves_push(event)
      var element = event.target;
      var position_in_drag_enter = _.find(drag_enters, _.partial(_.isEqual, element));
      if(!_.isUndefined(position_in_drag_enter))
        drag_enters.splice(position_in_drag_enter,1);
      
      if(_.isEmpty(drag_enters))
        set_document_drag_state(false);
      
    

    $document.bind("dragenter",function(event)
      console.log("enter", "doc","drag", event);
      drag_enters_push(event);
    );

    $document.bind("dragleave",function(event)
      console.log("leave", "doc", "drag", event);
      drag_leaves_push(event);
      console.log(drag_enters.length);
    );

    $document.bind("drop",function(event)
      reset_drag();
      console.log("drop","doc", "drag",event);
    );
  
;

)

我使用一个列表来表示触发了拖入事件的元素。当拖动离开事件发生时,我在拖动输入列表中找到匹配的元素,将其从列表中删除,如果结果列表为空,我知道我已拖动到文档/窗口之外。

我需要在放置事件发生后重置包含拖动元素的列表,或者下次我开始拖动某些内容时,列表将填充上次拖放操作中的元素。

到目前为止,我只在 chrome 上对此进行了测试。我这样做是因为 Firefox 和 chrome 有不同的 HTML5 DND API 实现。 (拖放)。

真的希望这对某些人有所帮助。

【讨论】:

【参考方案12】:

当文件进入和离开子元素时,它会触发额外的dragenterdragleave,因此您需要向上和向下计数。

var count = 0

document.addEventListener("dragenter", function() 
    if (count === 0) 
        setActive()
    
    count++
)

document.addEventListener("dragleave", function() 
    count--
    if (count === 0) 
        setInactive()
    
)

document.addEventListener("drop", function() 
    if (count > 0) 
        setInactive()
    
    count = 0
)

【讨论】:

【参考方案13】:

我通过查看spec 发现,如果dragEnd 上的evt.dataTransfer.dropEffect 匹配none,那么它就是取消。

我确实已经使用该事件来处理复制而不影响剪贴板。所以这对我有好处。 当我点击 Esc 然后下降效果等于none

window.ondragend = evt => 
  if (evt.dataTransfer.dropEffect === 'none') abort
  if (evt.dataTransfer.dropEffect === 'copy') copy // user holds alt on mac
  if (evt.dataTransfer.dropEffect === 'move') move 

【讨论】:

【参考方案14】:

在“dropend”事件中,您可以检查 document.focus() 的值,这对我来说是魔术。

【讨论】:

这应该是一条评论

以上是关于如何像 Gmail 一样检测进入和离开窗口的 HTML5 拖动事件?的主要内容,如果未能解决你的问题,请参考以下文章

如果通知中心像 Dropbox 和 1Password 一样简单地显示,我如何检测用户解锁设备但不检测?

加载 Silverlight 窗口后检测鼠标位置

带有 UiSplitview 的滑动窗口,例如横向和纵向的 gmail iPad 应用程序

检测用户是不是进入或离开区域 - 地理编码

是否可以像在 gmail 中一样在 php 中发出“哔”声

怎样实现鼠标滚轮翻页时,当某个div进入窗口时该div的动画播放,div离开窗口时动画回放。