最早在网页中引入JavaScript拖放功能是IE4。当时,网页中只有两种对象可以拖放:图像和某些文本。拖放图像时,把鼠标放到图像上,按住鼠标不放就可以拖放它。拖放文本时,要先选中文本,然后可以像拖放图像一样拖放被选中的文本。在IE4中,唯一有效的放置目标是文本框。到了IE5,拖放功能得到拓展,添加了新的事件,而且几乎网页中的任何元素都可以作为放置目标。IE5.5更进一步让网页中的任何元素都可以拖放。HTML5以IE的实例为基础指定了拖放规范。

拖放事件

通过拖放事件,可以控制拖放相关的各个方面。其中最关键的地方在于确定那里发生了拖放事件,有些事件是在被拖动的元素上触发的,而有些事件是在放置目标上触发的。拖动某些元素时,将一次触发下列事件:

  • dragstart
  • drag
  • dragend

按下鼠标键并开始移动鼠标时,会在被拖放的元素上触发dragstart事件。此时光标变成“不能放”符号(圆环中有一条反斜线),表示不能把元素放到自己上面。拖动开始时,可以通过ondragstart事件处理程序来运行JavaScript代码。

触发dragstart事件后,随即会触发drag事件,而且在元素被拖动期间会持续发送该事件。这个事件与mousemove事件相似,在鼠标移动过程中,mousemove事件也会持续发生。当拖动停止时(无论是把元素放到了有效的放置目标,还是放到了无效的放置目标上),会触发dragend事件。

上述三个事件的目标都是被拖动的元素。默认情况下,浏览器不会在拖动期间改变被拖动元素的外观,但你可以自己修改。不过,大多数浏览器会为正被拖动的元素创建一个半透明的副本,这个副本始终跟随着光标移动。

当元素被拖放到一个有效的放置目标上时,下列事件会依次发生:

  • dragenter
  • dragover
  • dragleave或drop

只要元素被拖动到放置目标上,就会触发dragenter事件(类似于mouseover事件)。紧随其后的是dragover事件,而且被拖动的元素还在放置目标的范围内移动时,就会持续触发该事件。如果元素被拖出了放置目标,dragover事件不再发生,但会触发dragleave事件(类似于mouseout事件)。如果元素被放到了放置目标中,则会触发drop事件而不是dragleave事件。上述三个事件的目标都是作为放置目标的元素。

自定义放置目标

在拖动元素经过某些无效放置目标时,可以看到一种特殊的光标(圆环中有一条反斜线),表示不能放置。虽然所有元素都支持放置目标事件,但这些元素默认是不允许放置的。如果拖动元素经过不允许放置的元素,无论用户如何操作,都不会发生drop事件。不过,你可以把任何元素变成有效的放置目标,方法是重写dragenter和dragover事件的默认行为。例如,假设有一个ID为“droptarget”的<div>元素,可以用如下代码将它变成一个放置目标。

var EventUtil = {
    addHandler: function (element, type, handler) {
        if (element.addEventListener) {
            element.addEventListener(type, handler, false);
        } else if (element.attachEvent) {
            element.attachEvent("on" + type, handler);
        } else {
            element["on" + type] = handler;
        }
    },
    preventDefault: function (event) {
        if (event.preventDefault) {
            event.preventDefault();
        } else {
            event.returnValue = false;
        }
    }
};

var droptarget = document.getElementById("droptarget");
EventUtil.addHandler(droptarget, "dragover", function (event) {
    EventUtil.preventDefault(event);
});
EventUtil.addHandler(droptarget, "dragenter", function (event) {
    EventUtil.preventDefault(event);
});

以上代码执行后,你就会发现拖动着元素移动到放置目标上时,光标变成了允许放置的符号。当然,释放鼠标也会触发drop事件。

在Firefox 3.5+中,放置事件的默认行为是打开被放到放置目标上的URL。换句话说,如果是把图像拖放到放置目标上,页面就会转向图像文件:而如果是把文本拖放到放置目标上,则会导致无效URL错误。因此,为了让Firefox支持正常的拖放,还要取消drop事件的默认行为,阻止它打开URL:

EventUtil.addHandler(droptarget, "drop", function (event) {
    EventUtil.preventDefault(event);
});

dataTransfer对象

只有简单的拖放而没有数据变化是没有什么用的。为了在拖放操作时实现数据交换,IE 5引入了dataTransfer对象,它是事件对象的一个属性,用于从被拖动元素向放置目标传递字符串格式的数据。因为它是事件对象的属性,所以只能在拖放事件的事件处理程序中访问dataTransfer对象。在事件处理程序中,可以使用这个对象的属性和方法来 完善拖放功能。目前,HTML5规范草案也收入了dataTransfer对象。

dataTransfer对象有两个主要方法: getData()和setData()。不难想象,getData()可以取得由setData()保存的值。setData()方法的第一个参数,也是getDAta()方法唯一的一个参数,是一个字符串,表示保存的数据类型,取值为”text”或“URL”,如下所示:

//设置和接收文本数据
event.dataTransfer.setData("text", "some text");
var text = event.dataTransfer.getData("text");

//设置和接收URL
event.dataTransfer.setData("URL", "https://blog.arayofsunshine.dev/");

IE只定义了“text”和“URL”两种有效的数据类型,而HTML5则对此加以扩展,允许指定各种MIME类型。考虑到向后兼容,HTML5也支持“text”和“URL”,但这两种类型会被映射为“text/plain”和“text/uri-list”。

实际上,dataTransfer对象可以为每种MIME类型都保存一个值。换句话说,同时在这个对象中保存了一段文本和一个URL不会有任何问题。不过,保存在dataTransfer对象中的数据只能在drop事件处理程序中读取。如果在ondrop处理程序中没有读到数据,那就是dataTransfer对象已经被销毁,数据也丢失了。

在拖动文本框中的文本时,浏览器会调用setData()方法,将拖动的文本以“text”格式保存在dataTransfer对象中。类似地,在拖放链接或图像时,会调用setData()方法并保存URL。然后,在这些元素被拖放到放置目标时,就可以通过getData()读到这些数据。当然,作为开发人员,你也可以在dragstart事件处理程序中调用setData(),手工保存自己要传输的数据,以便将来使用。

将数据保存为文本和保存为URL是有区别的。如果将数据保存为文本格式,那么数据不会得到任何特殊处理。而如果将数据保存为URL,浏览器会将其当成网页中的链接。换句话说,如果你把它放置到另一个浏览器窗口中,浏览器就会打开该URL。

Firefox在其第5个版本之前不能正确地将“URL”和“text”映射为“text/uri-list”和“text/plain”。但是却能把“Text”映射为“text/plain”。为了更好地在跨浏览器的情况下从dataTransfer对象取得数据,最好在取得URL数据时检测两个值,而在取得文本数据时使用“text”。

var dataTransfer = event.dataTransfer;
//读取URL
var url = dataTransfer.getData("url") || dataTransfer.getData("text/uri-list");
//读取文本
var text = dataTransfer.getData("Text");

注意,一定要把短数据类型放在前面,因为IE 10及之前的版本仍然不支持扩展的MIME类型名,而它们在遇到无法识别的数据类型时,会抛出错误。

dropEffect与effectAllowed

利用dataTransfer对象,可不光是能够传输数据,还能通过它来确定被拖动的元素以及作为放置目标的元素能够接受什么操作。为此,需要访问dataTransfer对象的两个属性:dropEffect和effectAllowed。

其中,通过dropEffect属性可以知道被拖动的元素能够执行哪种放置行为。这个属性有下列4个可能的值。

  • “none”:不能把拖动的元素放在这里。这是除文本框之外所有元素的默认值。
  • “move”:应该把拖动的元素移动到放置目标。
  • “copy”:应该把拖动的元素复制到放置目标。
  • “link”:表示放置目标会打开拖动的元素(但拖动的元素必须是一个链接,有URL)。

在把元素拖动到放置目标上时,以上每一个值都会导致光标显示为不同的符号。然而,要怎样实现光标所指示的动作完全取决于你。换句话说,如果你不介入,没有什么会自动地移动、复制,也不会打开链接。总之,浏览器只能帮你改变光标的样式,而其它的都要靠你自己来实现。要使用dropEfect属性,必须在ondraggenter事件处理程序中针对放置目标来设置它。

dropEffect属性只有搭配effectAllowed属性才有用。effectAllowed属性表示允许拖放元素的哪种dropEffect,effectAllowed属性可能的值如下。

  • “uninitialized”:没有该被拖动元素放置行为。
  • “none”:被拖动的元素不能有任何行为。
  • “copy”:只允许值为“copy”的dropEffect。
  • “link”:只允许值为“link”的dropEffect。
  • “move”:只允许值为“move”的dropEffect。
  • “copyLink”:允许值为“copy”和“link”的dropEffect。
  • “copyMove”:允许值为“copy”和”link”的dropEffect。
  • “linkMove”:允许职位“link”和”move”的dropEffect。
  • “all”:允许任意dropEffect。

必须在ondraggstart事件处理程序中设置effectAllowed属性。

假设你想允许用户把文本框中的文本拖放到一个<div>元素中。首先,必须将dropEffect和effectAllowed设置为”move”。但是,由于<div>元素的放置事件的默认行为是什么也不做,所以文本不可能自动移动。重写这个默认行为,就能从文本框中移走文本。然后你就可以自己编写代码将文本插入到<div>中,这样整个拖放操作就完成了。如果将dropEffect和effectAllowed的值设置为”copy”,那就不会自动移走文本框中的文本。

下面是一个综合实例:

<a href="https://blog.arayofsunshine.dev">拖动到红色区块打开链接</a>
<div style="width: 100px; height: 100px; float: right; background: red"
id="droptarget"></div>
<div id="output"></div>
<script type="text/javascript">
    var EventUtil = {
        addHandler: function (element, type, handler) {
            if (element.addEventListener) {
                element.addEventListener(type, handler, false);
            } else if (element.attachEvent) {
                element.attachEvent("on" + type, handler);
            } else {
                element["on" + type] = handler;
            }
        },

        preventDefault: function (event) {
            if (event.preventDefault) {
                event.preventDefault();
            } else {
                event.returnValue = false;
            }
        },

    };

    var droptarget = document.getElementById("droptarget");
    var link = document.links[0];

    function handleEvent(event) {
        document.getElementById("output").innerHTML += event.type + "<br>";
        switch (event.type) {
        case "dragstart":
        case "draggesture":
            event.dataTransfer.dropEffect = "link";
            break;
        case "dropenter":
        case "dragover":
            EventUtil.preventDefault(event);
            event.dataTransfer.effectAllowed = "all";
            break;
        case "drop":
        case "dragdrop":
            droptarget.innerHTML = event.dataTransfer.getData("url") || event.dataTransfer.getData("text/uri-list");
        }

    }

    EventUtil.addHandler(droptarget, "dragenter", handleEvent);
    EventUtil.addHandler(droptarget, "dragover", handleEvent);
    EventUtil.addHandler(droptarget, "dragleave", handleEvent);
    EventUtil.addHandler(droptarget, "drop", handleEvent);

    EventUtil.addHandler(link, "dragstart", handleEvent);
</script>

draggable

默认情况下,图像、链接和文本是可以拖动的,也就是说,不用额外编写代码,用户就可以拖动它们。文本只有在被选中的情况下才能拖动,而图像和链接在任何时候都可以拖动。

让其它元素可以拖动也是可能的。HTML5为所有HTML元素规定了一个draggable属性,表示元素是否可以拖动。图像和链接的draggable属性自动被设置成了true,而其它元素这个属性的默认值都是false。要想让其它元素可拖动,或者让图像或链接不能拖动,都可以设置这个属性。例如:

<!-- 让这个图像不可以拖动 -->
<img src="smile.gif" draggable="false" alt="Smiley face">
<!-- 让这个元素可以拖动 -->
<div draggable="true">...</div>

支持draggable属性的浏览器有IE10+、Firefox 4+、Safari 5+和Chrome。Opera 11.5以及之前的版本都不支持HTML5的拖放功能。另外,为了让Firefox 支持可拖动属性,还必须添加一个ondragstart事件处理程序,并在dataTransfer对象中保存了一些信息。

其它方法和属性

HTML5规范规定dataTransfer对象还应该包含下列方法和属性。

  • addElement(element):为拖动操作添加一个元素。添加这个元素只影响数据(即增加作为拖动源而影响回调的对象),不会影响拖动操作时页面元素的外观。在写作文本时,只有Firefox 3.5+实现了这个方法。
  • clearData(format):清除以特定格式保存的数据。实现这个方法的浏览器有IE、Firefox 3.5、Chrome和Safari 4+。
  • setDragImage(element, x, y):指定一副图像,当拖动发生时,显示在光标下方。这个方法接受的三个参数分别是要显示的HTML元素和光标在图像中的x、y坐标。其中,HTML元素可以是一副图像,也可以是其它元素。是图像则显示图像,是其它元素则显示渲染后的元素。实现这个方法的浏览器有Firefox 3.5+、Safari 4+ 和 Chorme。
  • types:当前保存的数据类型。这是一个类似数组的集合,以“text”这样的字符串形式保存着数据类型。实现这个属性的浏览器有IE10+、Firefox 3.5+和Chrome。

示例

拖放是一种非常流行的用户界面模式。它的概念很简单:点击某个对象,并按住鼠标按钮不放,将鼠标移动到另一个区域,然后释放鼠标按钮讲对象“放”在这里。拖放功能也流行到了Web上,成为了一些更传统的配置界面的一种候选方案。

拖放的基本概念很简单:创建一个绝对定位的元素,使其可以用鼠标移动。这个技术源自一种叫做“鼠标拖尾”的经典网页技巧。鼠标拖尾是一个或者多个图片在页面上跟着鼠标指针移动。单元素鼠标拖尾的基本代码需要为文档设置一个onmousemove事件处理程序,它总是将指定元素移动到鼠标指针的位置,如下面的例子所示:

var EventUtil = {
    addHandler: function (element, type, handler) {
        if (element.addEventListener) {
            element.addEventListener(type, handler, false);
        } else if (element.attachEvent) {
            element.attachEvent("on" + type, handler);
        } else {
            element["on" + type] = handler;
        }
    }
};

EventUtil.addHandler(document, "mousemove", function (event) {
    var myDiv = document.getElementById("myDiv");
    myDiv.style.left = event.clientX + "px";
    myDiv.style.top = event.clientY + "px";
});

在这个例子中,元素的left和top坐标设置为了event对象的clientX和clientY属性,这就将元素放到了视口中指针的位置上。它的效果是一个元素始终跟随指针在页面上的移动。只要正确的时刻(当鼠标按钮按下的时候)实现该功能,并在之后删除它(当释放鼠标按钮时),就可以实现拖放了。最简单的拖放界面可以用以下代码实现:

var DragDrop = function () {
        var dragging = null;
        function handleEvent(event) {
            //获取事件和目标
            event = EventUtil.getEvent(event);
            var target = EventUtil.getTarget(event);
            //确定事件类型
            switch (event.type) {
            case "mousedown":
                if (target.className.indexOf("draggable") > -1) {
                    dragging = target;
                }
                break;
            case "mousemove":
                if (dragging !== null) {

                    //获取事件
                    event = EventUtil.getEvent(event);

                    //指定位置
                    dragging.style.left = event.clientX + "px";
                    dragging.style.top = event.clientY + "px";
                }
                break;
            case "mouseup":
                dragging = null;
                break;
            }
        };
        //公共接口
        return {
            enable: function () {
                EventUtil.addHandler(document, "mousedown", handleEvent);
                EventUtil.addHandler(document, "mousemove", handleEvent);
                EventUtil.addHandler(document, "mouseup", handleEvent);
            },
            disable: function () {
                EventUtil.removeHandler(document, "mousedown", handleEvent);
                EventUtil.removeHandler(document, "mousemove", handleEvent);
                EventUtil.removeHandler(document, "mouseup", handleEvent);
            }
        }
    }();

//启用拖放
DragDrop.enable();

DragDrop对象封装了拖放的所有基本功能,这是一个单例对象,并使用了模块模式来隐藏某些实现细节。dragging变量起始是null,将会存放被拖动的元素,所以当该变量不为null时,就知道正在拖动某个东西。handleEvent()函数处理拖放功能中的所有的三个鼠标时间。它首先获取event对象和事件目标的引用。之后,用一个switch语句确定要触发那个事件样式。当mousedown时间发生时,会检查target的class是否包含“draggable”类,如果是,那么将target存放到dragging中,这个技巧可以很方便地通过标记语言而非JavaScript脚本来确定可拖动的元素。

handleEvent()的mousemove情况和前面的代码一样,不过要检查dragging是否为null。当它不是null,就知道dragging就是要拖动的元素,这样就会把它放到恰当的位置上。mouseup情况就仅仅是将dragging重置为null,让mousemove事件中的判断失效。

DragDrop还有两个公共方法:enable()和disable(),它们只是相应添加和删除所有的事件处理程序。这两个函数提供了额外的对拖放功能控制手段。

要使用DragDrop对象,至啊哟在页面上包含这些代码并调用enable()。拖放会自动针对所有包含“draggable”类的元素启用,如下例所示:

<div class="draggable" style="position:absolute; background:red"></div>

注意为了元素能被拖放,它必须是绝对定位的。

修缮拖动功能

当你试了上面的例子之后,你会发现元素的左上角总是和指针在一起。这个结果对用户来说有一点不爽,因为当鼠标开始移动的时候,元素好像是突然跳了一下。理想的情况是,这个动作应该看上去好像是这个元素是被指针“拾起”的,也就是说当在拖动元素的时候,用户点击的那一点就是指针应该保持的位置。

要达到需要的效果,必须做一些额外的计算。你需要计算元素左上角和指针位置之间的差值。这个差值应该在mousedown事件发生的时候确定,并且一直保持,直到mouseup事件发生。通过将event的clientX和clientY属性与该元素的offsetLeft和offsetTop属性进行比较,就可以算出水平方向和垂直方向上需要多少空间。

修缮拖动功能

为了保存x和y坐标上的差值,还需要几个变量。diffX和diffY这些变量需要在onmousemove事件处理程序中用到,来对元素进行适当的定位,如下面的例子所示:

var DragDrop = function () {
        var dragging = null;
        var diffX = 0;
        var diffY = 0;

        function handleEvent(event) {
            //获取事件和目标
            event = EventUtil.getEvent(event);
            var target = EventUtil.getTarget(event);
            //确定事件类型
            switch (event.type) {
            case "mousedown":
                if (target.className.indexOf("draggable") > -1) {
                    dragging = target;
                    diffX = event.clientX - target.offsetLeft;
                    diffY = event.clientY - target.offsetTop;
                }
                break;
            case "mousemove":
                if (dragging !== null) {
                    //获取事件
                    event = EventUtil.getEvent(event);
                    //指定位置
                    dragging.style.left = (event.clientX - diffX) + "px";
                    dragging.style.top = (event.clientY - diffY) + "px";
                }
                break;
            case "mouseup":
                dragging = null;
                break;
            }
        };
        //公共接口
        return {
            enable: function () {
                EventUtil.addHandler(document, "mousedown", handleEvent);
                EventUtil.addHandler(document, "mousemove", handleEvent);
                EventUtil.addHandler(document, "mouseup", handleEvent);
            },

            disable: function () {
                EventUtil.removeHandler(document, "mousedown", handleEvent);
                EventUtil.removeHandler(document, "mousemove", handleEvent);
                EventUtil.removeHandler(document, "mouseup", handleEvent);
            }
        }
    }();

//启用拖放
DragDrop.enable();

添加自定义事件

拖放功能还不能真正的应用起来,除非能知道什么时候拖放开始了。从这点上看,前面的代码没有提供任何方法表示拖动开始、正在拖动或者已经结束。这时,可以使用自定义事件来指示这几个事件的发生,让应用的其它部分与拖动功能进行交互。

由于DragDrop对象是一个使用了模块模式的单例,所以需要进行一些更改来使用EventTarget类型。首先,创建一个新的EventTarget对象,然后添加enable()和disable()方法,最后返回这个对象。看一下内容:

这段代码定义了3个事件:dragstart、drag和dragend。它们都将被拖动的元素设置为了target,并给出了x和y属性来表示当前的位置。它们触发于dragdrop对象上,之后在返回对象前给对象增加enable()和disable()方法。这些模块模式中的细小更改令DragDrop对象支持了事件,如下:

var EventUtil = {
    getEvent: function (event) {
        return event ? event : window.event;
    },
    getTarget: function (event) {
        return event.target || event.srcElement;
    },
    addHandler: function (element, type, handler) {
        if (element.addEventListener) {
            element.addEventListener(type, handler, false);
        } else if (element.attachEvent) {
            element.attachEvent("on" + type, handler);
        } else {
            element["on" + type] = handler;
        }
    },
    removeHandler: function (element, type, handler) {
        if (element.removeEventListener) {
            element.removeEventListener(type, handler, false);
        } else if (element.detachEvent) {
            element.detachEvent("on" + type, handler);
        } else {
            element["on" + type] = null;
        }
    }
};

function EventTarget() {
    this.handlers = {};
}

EventTarget.prototype = {
    constructor: EventTarget,

    addHandler: function (type, handler) {
        if (typeof this.handlers[type] == "undefined") {
            this.handlers[type] = [];
        }

        this.handlers[type].push(handler);
    },

    fire: function (event) {
        if (!event.target) {
            event.target = this;
        }
        if (this.handlers[event.type] instanceof Array) {
            var handlers = this.handlers[event.type];
            for (var i = 0, len = handlers.length; i < len; i++) {
                handlers[i](event);
            }
        }
    },

    removeHandler: function (type, handler) {
        if (this.handlers[type] instanceof Array) {
            var handlers = this.handlers[type];
            for (var i = 0, len = handlers.length; i < len; i++) {
                if (handlers[i] === handler) {
                    break;
                }
            }

            handlers.splice(i, 1);
        }
    }
};

var DragDrop = function () {
        var dragdrop = new EventTarget();
        var dragging = null;
        var diffX = 0;
        var diffY = 0;

        function handleEvent(event) {
            //获取事件和目标
            event = EventUtil.getEvent(event);
            var target = EventUtil.getTarget(event);
            //确定事件类型
            switch (event.type) {
            case "mousedown":
                if (target.className.indexOf("draggable") > -1) {
                    dragging = target;
                    diffX = event.clientX - target.offsetLeft;
                    diffY = event.clientY - target.offsetTop;
                    dragdrop.fire({
                        type: "dragstart",
                        target: dragging,
                        x: event.clientX,
                        y: event.clientY
                    });
                }
                break;
            case "mousemove":
                if (dragging !== null) {

                    //获取事件
                    event = EventUtil.getEvent(event);

                    //指定位置
                    dragging.style.left = (event.clientX - diffX) + "px";
                    dragging.style.top = (event.clientY - diffY) + "px";

                    //触发自定义事件
                    dragdrop.fire({
                        type: "drag",
                        target: dragging,
                        x: event.clientX,
                        y: event.clientY
                    });
                }
                break;
            case "mouseup":
                dragdrop.fire({
                    type: "dragend",
                    target: dragging,
                    x: event.clientX,
                    y: event.clientY
                });
                dragging = null;
                break;
            }
        };
        //公共接口
        dragdrop.enable = function () {
            EventUtil.addHandler(document, "mousedown", handleEvent);
            EventUtil.addHandler(document, "mousemove", handleEvent);
            EventUtil.addHandler(document, "mouseup", handleEvent);
        };
        dragdrop.disabled = function () {
            EventUtil.removeHandler(document, "mousedown", handleEvent);
            EventUtil.removeHandler(document, "mousemove", handleEvent);
            EventUtil.removeHandler(document, "mouseup", handleEvent);
        }
        return dragdrop;
    }();

//启用拖放
DragDrop.enable();

DragDrop.addHandler("dragstart", function (event) {
    var status = document.getElementById("status");
    status.innerHTML = "Started dragging " + event.target.id;
});
DragDrop.addHandler("drag", function (event) {
    var status = document.getElementById("status");
    status.innerHTML += "<br />Dragged " + event.target.id + " to (" + event.x + "," + event.y + ")";
});
DragDrop.addHandler("dragend", function (event) {
    var status = document.getElementById("status");
    status.innerHTML += "<br />Dragged " + event.target.id + " at (" + event.x + "," + event.y + ")";
});

这里,为DragDrop对象每个事件添加了事件处理程序。还是用了一个元素来实现被拖动的元素当前的状态和位置。一旦元素被放下了,就可以看到从它一开始被拖动之后经过的所有的中间步骤。

为DragDrop添加自定义事件可以使这个对象更健壮,它将可以在网络应用中处理复杂的拖放功能。

Related Posts