WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe  [Atom feed]  [Google]  [Bloglines]  [livedoor Reader]  [はてなRSS]  [Opera Widgetize]

使いやすい DHTML ポップアップなどを実現する「DragResize.js」を作りました

本日は、現在製作中のツールの副産物としてできた、 HTML 要素をドラッグ・リサイズするライブラリをご紹介します。 DHTML でポップアップウインドウなどを実現するのに便利です。そんなライブラリいくらでもあるよ!と言われそうですが、けっこう頑張って座標補正などしていて、以下の特徴があります。

  • 要素がドキュメントからはみ出さないように補正する。
  • ドラッグ中にスクロールしてもドラッグ位置がずれない。
  • 要素が表示領域外に出たときは自動スクロール。
  • DragResize クラスを除き、グローバルな名前空間を汚染しない。
  • 他のライブラリに依存せず、単独で動作する。
  • 若干の制限はあるものの、概ねクロスブラウザで動作する。

なるべくデスクトップに近い操作性を得られるように工夫したつもりです。とくに利用制限などはありませんので、改変や商用アプリへの組み込みなど、ご自由にお使いください。ただし、動作保証などはしませんので自己責任でお願いします。

ライブデモ

なにはともあれ、まずは動作しているところをご覧ください。以下が DragResize.js を使ったポップアップウインドウです。

タイトルバーの文字列以外の部分をドラッグすることで移動できますし、右下のボックスでリサイズできます。タイトルバーの文字列部分はリンクになっていて、クリックすればこのブログのトップページを表示します。ドラッグ中にホイールでスクロールさせたり、表示領域外に持っていったりと、いろいろ試してみてください。

ドラッグだけ、もしくはリサイズだけを機能させることも可能です。以下のテキストボックスはドラッグするとサイズ変更可能です。

ソース

ソースは以下のようになっています。

var DragResize = (function() {

  var $window         = window,
    $document         = document,
    $addEventListener = 'addEventListener',
    $offset           = 'offset',
    $client           = 'client',
    $scroll           = 'scroll',
    $min              = 'min',
    $left             = 'Left',
    $top              = 'Top',
    $width            = 'Width',
    $height           = 'Height',
    $drag             = 'drag',
    $resize           = 'resize',
    $handle           = 'Handle',
    $style            = 'style',
    $ignoreTags       = 'ignoreTags',
    $null             = null;

  var $body          = $document.body,
    $documentElement = $document.documentElement;

  var $methodWrapper = function(method, scope)
  {
    return function() { return method.apply(scope, arguments) };
  },
  $addEvent = function(element, type, proc, scope)
  {
    var listener = $methodWrapper(proc, scope);
    element[$addEventListener] ?
      element[$addEventListener](type, listener, false) :
      element.attachEvent('on' + type, listener);
    return { $element: element, $type: type, $listener: listener };
  },
  $removeEvent = function(info)
  {
    info.$element[$addEventListener] ?
      info.$element.removeEventListener(info.$type, info.$listener, false) :
      info.$element.detachEvent('on' + info.$type, info.$listener);
  },
  $stopEvent = function(event)
  {
    event.stopPropagation && event.stopPropagation();
    event.preventDefault  && event.preventDefault();
    event.cancelBubble = true;
    event.returnValue  = false;
  },
  $getElement = function(id)
  {
    return typeof id === 'string' ? $document.getElementById(id) : id;
  },
  $getScroll = (typeof $window.pageXOffset === 'number' ?
                function() {
                  return { x: $window.pageXOffset, y: $window.pageYOffset };
                } :
                ($documentElement && $documentElement[$client+$width] ?
                 function() {
                   return { x: $documentElement[$scroll+$left], y: $documentElement[$scroll+$top] };
                 } :
                 function() {
                   return { x: $body[$scroll+$left], y: $body[$scroll+$top] };
                 })),
  $getWindowSize = ($window.innerWidth ?
                    function() {
                      return { x: $window.innerWidth, y: $window.innerHeight };
                    } :
                    ($documentElement && $documentElement[$client+$width] ?
                     function() {
                       return { x: $documentElement[$client+$width], y: $documentElement[$client+$height] };
                     } :
                     function() {
                       return { x: $body[$offset+$width], y: $body[$client+$height] };
                     })),
  DragResize = function(container, options)
  {
    options                  = options || {};
    var self                 = this;
    options[$resize+$handle] = $getElement(options[$resize+$handle]);
    self.$container          = (container = $getElement(container));
    self.$minWidth           = options[$min+$width];
    self.$minHeight          = options[$min+$height];
    self[$scroll]            = options[$scroll];
    self.$onClickCallback    = options['onclick'];
    self.$onClickScope       = options['scope'];
    self.$ignoreTags         = {};
    self.$events             = [];
    if((options[$drag+$handle] = $getElement(options[$drag+$handle])) || options[$resize+$handle] != container)
    {
      self.$dragHandle = options[$drag+$handle] || container;
      self.$events.push($addEvent(self.$dragHandle, 'mousedown', self.$drag_onMouseDown, self));
      self.$events.push($addEvent(self.$dragHandle, 'click',     self.$onClick,          self));
    }
    if(self.$dragHandle != container || (options[$resize+$handle] && options[$resize+$handle] != self.$dragHandle))
    {
      self.$resizeHandle = options[$resize+$handle] || container;
      self.$events.push($addEvent(self.$resizeHandle, 'mousedown', self.$resize_onMouseDown, self));
      self.$events.push($addEvent(self.$resizeHandle, 'click',     self.$onClick,            self));
    }
    var ignoreTags = options[$ignoreTags];
    if(typeof ignoreTags === 'string') {
      ignoreTags = [ignoreTags];
    }
    if(ignoreTags) {
      for(var i = 0 ; i < ignoreTags.length ; ++i) {
        self.$ignoreTags[ignoreTags[i].toUpperCase()] = true;
      }
    }
  };

  DragResize[$min+$width]    = 100;
  DragResize[$min+$height]   = 100;
  DragResize.$dragInfo        = $null;
  DragResize.$ie              = !!$window.ActiveXObject;

  DragResize.$onMouseMove = function(event)
  {
    var info = DragResize.$dragInfo;
    if(info)
    {
      if(DragResize.$ie &&
         !(document.documentMode && document.documentMode >= 8) &&
         !(event.button & 1))
      {
        DragResize.$finish();
        return;
      }
      info.$currentX = event.clientX;
      info.$currentY = event.clientY;
      DragResize.$ie && $stopEvent(event);
    }
  };

  DragResize.$onMouseUp = function(event)
  {
    var info = DragResize.$dragInfo, scroll = $getScroll();
    if(info) {
      var self = info.$manager;
      if(self && typeof self.$onClickCallback === 'function' &&
         info.$clickX + info.$baseScX == event.clientX + scroll.x &&
         info.$clickY + info.$baseScY == event.clientY + scroll.y) {
        self.$onClickCallback.call(self.$onClickScope, event);
      }
    }
    DragResize.$finish();
  };

  DragResize.$finish = function()
  {
    var info = DragResize.$dragInfo;
    info && info.$intervalId && clearInterval(info.$intervalId);
    DragResize.$dragInfo = $null;
  };

  DragResize.prototype = {

    $drag_onMouseDown : function(event)
    {
      var self = this;
      if(self.$checkIgnoreTags(event))
        return;
      $stopEvent(event);
      var info    = DragResize.$dragInfo = self.$beginDrag(event, self.$drag_onInterval),
        container = self.$container;
      info.$baseX = container[$offset+$left];
      info.$baseY = container[$offset+$top];
      info.$minX  = 0;
      info.$minY  = 0;
      info.$maxX  = $documentElement[$scroll+$width]  - container[$offset+$width];
      info.$maxY  = $documentElement[$scroll+$height] - container[$offset+$height];
    },

    $drag_onInterval : function()
    {
      this.$updateDrag(function(info, container, x, y, scroll) {
        container[$style].left = x + 'px';
        container[$style].top  = y + 'px';
        if(this[$scroll])
        {
          var frameSize = $getWindowSize(),
            right = x + container[$offset+$width], bottom = y + container[$offset+$height],
            sx = scroll.x, sy = scroll.y;
          x < sx ? (sx = x) : (right > (sx + frameSize.x) && (sx = right - frameSize.x));
          y < sy ? (sy = y) : (bottom > (sy + frameSize.y) && (sy = bottom - frameSize.y));
          (sx != scroll.x || sy != scroll.y) && $window[$scroll](sx, sy);
        }
      });
    },

    $resize_onMouseDown : function(event)
    {
      var self = this;
      if(self.$checkIgnoreTags(event))
        return;
      $stopEvent(event);
      var info      = DragResize.$dragInfo = self.$beginDrag(event, self.$resize_onInterval),
        container   = self.$container,
        minWidth    = (typeof self.$minWidth  === 'number' ? self.$minWidth  : DragResize[$min+$width]),
        minHeight   = (typeof self.$minHeight === 'number' ? self.$minHeight : DragResize[$min+$height]);
      info.$baseX   = container[$offset+$left] + container[$offset+$width];
      info.$baseY   = container[$offset+$top]  + container[$offset+$height];
      info.$adjustX = container[$offset+$width]  - container[$client+$width];
      info.$adjustY = container[$offset+$height] - container[$client+$height];
      info.$minX    = container[$offset+$left] + info.$adjustX + minWidth;
      info.$minY    = container[$offset+$top]  + info.$adjustY + minHeight;
      info.$maxX    = $documentElement[$scroll+$width];
      info.$maxY    = container[$style].position != 'absolute' ? 65536 : $documentElement[$scroll+$height];
    },

    $resize_onInterval : function()
    {
      this.$updateDrag(function(info, container, x, y, scroll) {
        container[$style].width  = (x - container[$offset+$left] - info.$adjustX) + 'px';
        container[$style].height = (y - container[$offset+$top]  - info.$adjustY) + 'px';
        if(this[$scroll])
        {
          var frameSize = $getWindowSize(),
            left        = container[$offset+$left], top = container[$offset+$top],
            sx          = scroll.x, sy = scroll.y;

          left < sx              && (sx = left);
          x > (sx + frameSize.x) && (sx = x - frameSize.x);
          top < sy               && (sy = top);
          y > (sy + frameSize.y) && (sy = y - frameSize.y);
          (sx != scroll.x || sy != scroll.y) && $window[$scroll](sx, sy);
        }
      });
    },

    $onClick : function(event) {
      if(!this.$checkIgnoreTags(event))
        $stopEvent(event);
    },

    $checkIgnoreTags : function(event) {
      var element = event.target || event.srcElement;
      while(element) {
        if(element.nodeType == 1 && this.$ignoreTags[element.tagName.toUpperCase()])
          return element;
        element = element.parentNode;
      }
      return null;
    },

    $beginDrag : function(event, updateProc)
    {
      $stopEvent(event);
      DragResize.$finish();
      var container = this.$container, scroll = $getScroll(),
        mouseX = event.clientX, mouseY = event.clientY,
        scrollX = scroll.x, scrollY = scroll.y;
      return {
        $manager:    this,
        $intervalId: setInterval($methodWrapper(updateProc, this), 30),
        $clickX:     mouseX,
        $clickY:     mouseY,
        $currentX:   mouseX,
        $currentY:   mouseY,
        $prevX:      mouseX,
        $prevY:      mouseY,
        $baseScX:    scrollX,
        $baseScY:    scrollY,
        $currentScX: scrollX,
        $currentScY: scrollY,
        $prevScX:    scrollX,
        $prevScY:    scrollY
      };
    },

    $updateDrag : function(proc)
    {
      var info = DragResize.$dragInfo, scroll = $getScroll();
      if(info.$currentX   != info.$prevX || info.$currentY   != info.$prevY ||
         info.$currentScX != scroll.x    || info.$currentScY != scroll.y)
      {
        var container = this.$container,
          x = Math.min(Math.max(info.$baseX + info.$currentX - info.$clickX + scroll.x - info.$baseScX, info.$minX), info.$maxX),
          y = Math.min(Math.max(info.$baseY + info.$currentY - info.$clickY + scroll.y - info.$baseScY, info.$minY), info.$maxY);
        proc.call(this, info, container, x, y, scroll);
        info.$prevX      = info.$currentX;
        info.$prevY      = info.$currentY;
        info.$currentScX = scroll.x;
        info.$currentScY = scroll.y;
      }
    },

    detach : function()
    {
      var self = this;
      DragResize.$dragInfo && DragResize.$dragInfo.$manager == self &&  DragResize.$finish();
      while(self.$events.length > 0)
        $removeEvent(self.$events.pop());
      self.$container = self.$dragHandle = self.$resizeHandle = $null;
    }

  };

  $addEvent($document, 'mouseup',   DragResize.$onMouseUp,   DragResize);
  $addEvent($document, 'mousemove', DragResize.$onMouseMove, DragResize);

  return DragResize;

})();

以下のリンクでダウンロードできます。

http://webos-goodies.googlecode.com/svn/trunk/prod... (非圧縮版)
http://webos-goodies.googlecode.com/svn/trunk/prod... (YUI Compressor + jsjuicer による圧縮版)

お好みのほうをご利用ください。

使い方

基本的には、 DragResize クラスのオブジェクトを作成するだけで、指定した要素をドラッグ・リサイズできます。コンストラクタの書式は以下のとおりです。

new DragResize(対象要素 [, オプション])

対象要素はドラッグ・リサイズする HTML 要素で、 ID の文字列か要素オブジェクトそのものが指定できます。オプションはオブジェクトで、以下の属性を指定できます。

属性名意味デフォルト
dragHaneleドラッグボックス要素操作対象
resizeHandleリサイズボックス要素なし
ignoreTagsドラッグ処理を開始しないタグなし
onclick単にクリックされた際に呼ばれる関数なし
scopeonclick呼び出し時のthisの内容null
minWidth最小の幅DragResize.minWidth
minHeight最小の高さDragResize.minHeight
scroll自動スクロール有効化false

ちょっとわかりにくいですが、 dragHandle はライブデモのポップアップウインドウのタイトルバーにあたる要素を指定するものです。これを指定すると、その要素でドラッグを開始したときのみ対象要素が移動します。省略すると対象要素そのものがドラッグボックスになるので、対象要素のどこでもドラッグを開始できます。

同様に、 resizeHandle で指定した要素でドラッグを開始したときのみ、対象要素がリサイズされます。もし対象要素のどこでもリサイズを開始したい場合は対象要素resizeHandle に同じものを指定してください。 dragHandleresizeHandle に同じものを指定すると、 dragHandle が優先されます。

ignoreTags にタグ名( 'A' など)やその配列を指定すると、その種類のタグのクリックは処理を行いません。ライブデモのタイトルバーのリンクはこの機能を使っています。 onclick はマウスカーソルをまったく移動させずにドラッグを終了した際(つまり単にクリックした際)に呼ばれるコールバック関数です。コールバック関数には、 this に scope の値が、第一引数に Event オブジェクトが、それぞれ渡されます。

その他、 minWidth, minHeight はリサイズ時の最小サイズの指定、 scroll対象要素が表示領域外に出たときに自動スクロールを行うかどうかの指定です(true で自動スクロール ON)。

ドラッグ・リサイズ機能の解除

DragResize オブジェクトの唯一のメソッドとして、 detach があります。これを呼ぶと、すべてのイベントハンドラを解除して、コンストラクタで指定した HTML 要素を切り離します。 HTML 要素を削除する前に必ず呼んでください。

<div id="frame" style="..."></div>

<script type="text/javascript">
var dragresize = new DragResize('frame');

function deleteFrame()
{
  dragresize.detach();
  var frame = document.getElementById('frame');
  frame.parentNode.removeChild(frame);
}
</script>

最小サイズのデフォルト値

前述のとおり、 minWidth, minHeight オプションのデフォルトは DragResize.minWidth, DragResize.minHeight の値になります。従って、以下のように値を代入することでデフォルト値を変更できます。

DragResize.minWidth  = 200;
DragResize.minHeight = 200;

サンプルコード

それでは、いくつか具体的なコードを挙げておきます。まずは単純にドラッグで移動できるだけのボックスを表示する例。

<div id="drag" style="position:absolute; width:100px; height:100px; background-color:gray"></div>
<script type="text/javascript">
  new DragResize('drag');
</script>

次は、同様にリサイズだけできるボックス。

<div id="resize" style="width: 100px; height: 100px; background-color:gray;"></div>
<script type="text/javascript">
  new DragResize('resize', {
    resizeHandle: 'resize', minWidth: 10, minHeight: 10 });
</script>

ライブデモのようなポップアップウインドウは、以下のコードで実現できます。

<style type="text/css">
#frame {
  position:         absolute;
  width:            200px;
  height:           100px;
  background-color: gray;
  border:           solid 1px black;
  z-index:          256;
}
#drag {
  text-align:       center;
  background-color: black;
  color:            white
}
#resize {
  position:   absolute;
  right:      2px;
  bottom:     2px;
  width:      16px;
  height:     16px;
  text-align: center;
  border:     solid 1px black;
}
</style>

<div id="frame">
  <div id="drag"><a href="http://webos-goodies.jp/">DragResize</a></div>
  <div id="resize">+</div>
</div>

<script type="text/javascript">
  var frame = document.getElementById('frame');
  new page.DragResize('frame', {
    dragHandle: 'drag', resizeHandle: 'resize', scroll: true, ignoreTags: 'A' });
</script>

スタイル指定が多いのはウインドウの見た目を作るためで、それを除いたコードは非常に簡単です。

制限事項

ブラウザの機能制限(?)やコードの複雑化を避けるため、以下のような制限があります。

  • ドラッグによる移動を行う場合、対象要素には "position:absolute" スタイルが設定されている必要があります。リサイズのみの場合は static などでも大丈夫です。
  • 対象要素は、ボーダーやマージン・パディングなどがない、素な DIV 要素が理想です。そうでない場合、座標の補正などがわずかにずれる場合があります。気にしならなければ問題ありませんが。なお、 dragHandleresizeHandle に指定する要素にはこのような制限はありません。
  • IE ではマウスカーソルがブラウザ外に出るとドラッグが止まってしまいます。
  • Firefox では、テキスト上でドラッグを開始したときのみ、マウスカーソルがブラウザ外に出るとドラッグが止まってしまいます(謎)。

とくに後半のブラウザ固有の問題は鋭意調査中です。解決法をご存知の方がおられましたら、教えていただけると助かります。

ご意見・ご要望・バグ報告など

最後にお決まりですが、 Google グループの告知を(^^;。 DragResize.js に関するご意見・ご要望・バグ報告には、以下の Google グループをご利用ください。 Google アカウントがあれば Web 上から投稿できますし、アカウントがなくてもメールで投稿できますので、お気軽にどうぞ。

Google グループ Beta
WebOS Goodiesに参加
メール アドレス:

以上、本日はドラッグアンドドロップによる要素の移動・リサイズを実現する「DragResize.js」をご紹介しました。既存のページにちょっとポップアップウインドウを追加したいけど、大規模なフレームワークは導入したくないといったことは意外に多いと思います。そんなときにぜひ使っていただけたらと思います。

関連記事

トラックバックURL

※システムの都合によりトラックバックの受け付けは中止しております。

この記事にコメントする