一個簡單的HTML5 Drag & Drop試作

 Tue, 05 Oct 2010 20:37:48 +0800

上週五跑去Google DevFest2010,聽到Kurrik提到,HTML5的Drag & Drop,還可以支援跨html文件及跨應用軟體。最近在研究HTML5相關的東西,有看到規格書第七章User Interaction裡面有一節在講Drag and Drop,但是沒有仔細看。聽過Kurrik的介紹以後,興趣來了,所以就先來試試。

過去要做出拖曳,需要用很複雜的方式偵測滑鼠位置來移動拖曳物件,HTML5把這個過程簡化了一些。Drag & Drop是靠幾個事件、物件跟HTMLElement屬性來協作的:

  1. DataTransfer 物件:透過Event.dataTransfer參數傳進Handler Function,這是在物件之間傳遞訊息的媒介。
  2. draggable 屬性:有設定這個屬性的物件才能被拖曳,否則可能會發生文字選擇的事件而不是拖曳的事件。
  3. ondragstart 事件:在被拖曳物件開始拖曳時觸發,這個事件作用在被拖曳物件
  4. ondragenter 事件:在被拖曳物件進入目的物件時觸發,這個事件作用在目標物件
  5. ondragover 事件:在被拖曳物件在目的物件上移動時觸發,這個事件作用在目標物件
  6. ondrop 事件:在被拖曳物件位於目的物件上放開滑鼠按鈕時觸發,這個事件作用在目標物件
  7. ondragend 事件:在ondrop之後觸發,這個事件作用在被拖曳物件
  8. Event.preventDefault() 方法:必須適時執行這個方法,來避免預設的動作被執行。在ondragover中一定要執行preventDefault(),ondrop事件才會被觸發。另外,從其他應用軟體或是文件拖曳東西進來,尤其是圖片時,預設的動作是顯示這個圖片或檔案,而不是真的執行drop。這個時候要用document的ondragover事件把他擋掉。
  9. Event.effectAllowed 屬性:指定要顯示的拖曳動態效果。

DataTransfer物件還是需要稍微介紹一下。他的定義可見HTML5標準文件的:7.9.2 The DragEvent and DataTransfer interfaces,其中對於DragEvent定義如下:

interface DragEvent : MouseEvent {
  readonly attribute DataTransfer dataTransfer;
  void initDragEvent(in DOMString typeArg, 
    in boolean canBubbleArg, 
    in boolean cancelableArg, 
    in any dummyArg, 
    in long detailArg, 
    in long screenXArg, 
    in long screenYArg, 
    in long clientXArg, 
    in long clientYArg, 
    in boolean ctrlKeyArg, 
    in boolean altKeyArg, 
    in boolean shiftKeyArg, 
    in boolean metaKeyArg, 
    in unsigned short buttonArg, 
    in EventTarget relatedTargetArg, 
    in DataTransfer dataTransferArg
  );
};
可以看到這個事件繼承了MouseEvent事件,然後加上一個dataTransfer屬性,DataTransfer物件的定義如下:
interface DataTransfer {
           attribute DOMString dropEffect;
           attribute DOMString effectAllowed;
  readonly attribute DOMStringList types;
  void clearData(in optional DOMString format);
  void setData(in DOMString format, in DOMString data);
  DOMString getData(in DOMString format);
  readonly attribute FileList files;
  void setDragImage(in Element image, in long x, in long y);
  void addElement(in Element element);
};

dataTransfer物件用setData(format, data)及getData(format)這兩個方法來送/收資料。通常會在ondragstart事件(也就是被拖曳的物件)上使用setData()來設定要傳送給目的物件的資料,然後在ondrop事件中(也就是目的物件)使用getData來取得資料。在format的指定上,我目前測試過的瀏覽器對於規格的理解有一些不同。從規格書提供的例子看起來,format應該可以自訂,但是在這兩個方法的說明中,只提到到Text, text/plain, url等(請自己看規格書)。結果是Firefox可以自由地指定format字串,而Chrome7只能使用text, text/plain, url, text/html等,最後測試過,因為一些內部處理過程不太一樣,兩個的交集大概只有text/plain,所以我的例子只使用text/plain。指定format是url時,Chrome7會對url格式的合法性做過濾(規格書有提到),而Firefox不會。反正實作有一些差異,兩個跟規格書的意思也不一定一致。

另外,effectAllowed跟dropEffect則控制拖曳的視覺效果。通常會在ondragstart設定effectAllowed而在ondragover設定dropEffect,而且這兩個值需要匹配。例如如果我設定effectAllowed為'copy'而dropEffect為'move'的話,就沒辦法觸發ondrop事件。effectAllowed如果是'all'或者不指定,那dropEffect只要不設定為'none',就都可以觸發ondrop事件。理論上改變dropEffect會出現不同的視覺效果,但是在Firefox4上面測試時沒看到...

files會在從另外一個文件或應用軟體拖曳物件進來時用到,我暫不介紹了,另外在寫一篇文章來介紹這些更強大的功能。types則會在使用setData或是從其他應用軟體拖曳物件進來時,設定一些資訊。例如setData時format參數使用'text/plain',這裡就會出現一筆值是'text/plain'的資料,可以用來取得傳進來的format。如果從其他應用軟體拖曳物件/檔案,使得files有File物件加入的話,這裡會有一筆資料值是'Files',可以用來判斷。根據拖曳東西的不同,也可能傳入一些檔案名稱、mime type、url等資料,這還需要進一步探索,而且會因使用的瀏覽器而有不同的結果。

測試時碰到另外一個問題,就是chrome的開發工具或是Firebug不一定可靠...我本來偷懶用console.log來探索DataTransfer,但是...開發工具跑出來的資訊跟實際執行結果並不一致(裡如某個物件的length),所以要確定是否可用,用程式來偵測還是比較可靠的。

接下來看一下我寫的簡單試作:

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
  .container {
    background: #336699;
    width: 120px;
    height: 120px;
    border: solid #6699CC 2px;
    padding: 3px 3px 3px 3px;
    border-radius: 5px;
  }
  .object {
    border: solid #244872 2px;
    border-radius: 3px;
    text-align: center;
    background: #99CCFF;
    padding: 2px 2px 2px 2px;
    cursor: move;
  }
</style>
<script>
function ListIterator(o, cb) {
  for(var i=0; i<o.length; i++) {
    cb(o.item(i));
  }
}
</script>
</head>
<body>
<div id="panel1" class="container">
<div id="source1" class="object">Test1</div>
<div id="source2" class="object">Test2</div>
<div id="source3" class="object">Test3</div></div>
<br>
<div id="panel2" class="container"></div>
</body>
</html>
<script>
(function(){
document.ondragover = function(e){e.preventDefault();};
document.ondrop = function(e){e.preventDefault();};
ListIterator(
  document.getElementsByClassName('object'),
  function(o){o.draggable = true;}
);
ListIterator(
  document.getElementsByClassName('object'),
  function(o){
    o.draggable = true;
    o.addEventListener('dragstart', function(e) {
      //if(window.console) console.log('dragstart');
      e.dataTransfer.effectAllowed = 'all';
      e.dataTransfer.setData('text/plain', this.id);
    },false);
    o.addEventListener('dragend', function(e) {
      //if(window.console) console.log('dragend');
      e.preventDefault();
    },false);
    o.addEventListener('drop',function(e){
      e.preventDefault();
      e.stopPropagation();
    },false);
  }
);
ListIterator(
  document.getElementsByClassName('container'),
  function(o){
    o.addEventListener('dragenter', function(e){
      e.preventDefault();
      //if(window.console) console.log('dragenter');
    },false);
    o.addEventListener('dragover', function(e){
      e.preventDefault();
      //if(window.console) console.log('dragover');
      e.dataTransfer.dropEffect = 'copy';
    },false);
    o.addEventListener('drop', function(e){
      e.preventDefault();
      e.stopPropagation();
      //if(window.console) console.log('drop');
      //if(window.console) console.log(e.dataTransfer);
      if(e.dataTransfer.types.length>0) {
        for(var i=0; i<e.dataTransfer.types.length; i++) {
          if(e.dataTransfer.types[i] === 'text/plain') {
            var sourceid = e.dataTransfer.getData('text/plain');
            var source = document.getElementById(sourceid);
            var cloned = source.cloneNode(true);
            //if(window.console) console.log('cloned');
            cloned.draggable = true;
            cloned.addEventListener('dragstart', function(e) {
              //if(window.console) console.log('dragstart');
              e.dataTransfer.effectAllowed = 'all';
              e.dataTransfer.setData('text/plain', this.id);
            },false);
            cloned.addEventListener('dragend', function(e) {
              //if(window.console) console.log('dragend');
            },false);
            cloned.addEventListener('drop',function(e){
              e.preventDefault();
              e.stopPropagation();
            },false);
            e.target.appendChild(cloned);
            source.parentNode.removeChild(source);
          }
        }
      }
    },false);
  }
);
})();
</script>

接下來看一下執行的結果:

這是Chrome7跑出來的畫面

這是在Chrome7拖曳後的畫面

這是Firefox4跑出來的畫面(沒有直接支援CSS3圓角...)

這是在Firefox4拖曳後的畫面

為了不要讓拖曳失誤,程式裡面特別針對document及被拖曳物件的ondrop做手腳,不過這樣使用有點不方便XDDD。不知道是不是可以加上一個droppable屬性來指定可以觸發ondrop事件阿?這樣很麻煩....不過也許是我的理解還不夠清楚。

另外,之後還會再測試做跨文件及跨應用軟體的拖曳,這部份會牽扯到File API,包括File, FileList, FileReader等,而且在DataTransfer物件上還是會看到瀏覽器實作的一些不同。


2010-10-06 20:11 更新

我在目的物件的ondrop方法中使用了Event.target,但是這樣會讓事件在...被拖曳物件中觸發時有不正確的動作,之前利用被拖曳物件的ondrop事件中使用stopPropagation()來避掉,不過這樣會很麻煩。比較好的方法是使用Event.currentTarget,或是在目的物件的ondrop事件中判斷target與currentTarget是否為同一物件。後面的方法會讓滑鼠在被拖曳物件的範圍內不會執行ondrop中的動作,在操作的直覺上感覺比較好,所以我把程式改成這樣動作,並且把多餘的程式刪除如下:

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
  .container {
    background: #336699;
    width: 120px;
    height: 120px;
    border: solid #6699CC 2px;
    padding: 3px 3px 3px 3px;
    border-radius: 5px;
  }
  .object {
    border: solid #244872 2px;
    border-radius: 3px;
    text-align: center;
    background: #99CCFF;
    padding: 2px 2px 2px 2px;
    cursor: move;
  }
</style>
<script>
function ListIterator(o, cb) {
  for(var i=0; i<o.length; i++) {
    cb(o.item(i));
  }
}
</script>
</head>
<body>
<div id="panel1" class="container">
<div id="source1" class="object">Test1</div>
<div id="source2" class="object">Test2</div>
<div id="source3" class="object">Test3</div></div>
<br>
<div id="panel2" class="container"></div>
</body>
</html>
<script>
(function(){
document.ondragover = function(e){e.preventDefault();};
document.ondrop = function(e){e.preventDefault();};
ListIterator(
  document.getElementsByClassName('object'),
  function(o){o.draggable = true;}
);
ListIterator(
  document.getElementsByClassName('object'),
  function(o){
    o.draggable = true;
    o.addEventListener('dragstart', function(e) {
      //if(window.console) console.log('dragstart');
      e.dataTransfer.effectAllowed = ['copy','move'];
      e.dataTransfer.setData('text/plain', this.id);
    },false);
  }
);
ListIterator(
  document.getElementsByClassName('container'),
  function(o){
    o.addEventListener('dragenter', function(e){
      e.preventDefault();
      //if(window.console) console.log('dragenter');
    },false);
    o.addEventListener('dragover', function(e){
      e.preventDefault();
      //if(window.console) console.log('dragover');
      e.dataTransfer.dropEffect = 'copy';
    },false);
    o.addEventListener('drop', function(e){
      e.preventDefault();
      e.stopPropagation();
      if(window.console) console.log(e.target.id);
      if(e.target!==e.currentTarget) return false;
      //if(window.console) console.log('drop');
      //if(window.console) console.log(e.dataTransfer);
      if(e.dataTransfer.types.length>0) {
        for(var i=0; i<e.dataTransfer.types.length; i++) {
          if(e.dataTransfer.types[i] === 'text/plain') {
            var sourceid = e.dataTransfer.getData('text/plain');
            var source = document.getElementById(sourceid);
            var cloned = source.cloneNode(true);
            //if(window.console) console.log('cloned');
            cloned.draggable = true;
            cloned.addEventListener('dragstart', function(e) {
              //if(window.console) console.log('dragstart');
              e.dataTransfer.effectAllowed = ['copy','move'];
              e.dataTransfer.setData('text/plain', this.id);
            },false);
            e.currentTarget.appendChild(cloned);
            source.parentNode.removeChild(source);
          }
        }
        return false;
      }
    },false);
  }
);
})();
</script>

2010-10-9 7:01 補充

早上看了一下DOM3 Event,在EventListener中提到,在使用Node.cloneNode()或是Range.cloneContents()複製Node時,不可複製EventListener過去。而在使用Document.adoptNode(), Node.appendChild()或Range.extractContents時,不可以影響到已經附加上去的EventListener。我在程式裡面使用了cloneNode,這樣會讓程式更複雜,因為需要重新addEventListener,所以把程式改動了一下,用removeChild + appendChild來做。下面只重貼ondrop的handler,也就是上一個程式從第70行開始的地方:

    o.addEventListener('drop', function(e){
      e.preventDefault();
      e.stopPropagation();
      if(window.console) console.log(e.target.id);
      if(e.target!==e.currentTarget) return false;
      //if(window.console) console.log('drop');
      //if(window.console) console.log(e.dataTransfer);
      if(e.dataTransfer.types.length>0) {
        for(var i=0; i<e.dataTransfer.types.length; i++) {
          if(e.dataTransfer.types[i] === 'text/plain') {
            var sourceid = e.dataTransfer.getData('text/plain');
            var source = document.getElementById(sourceid);
            e.currentTarget.appendChild(source.parentNode.removeChild(source));
          }
        }
        return false;
      }
    },false);