WebOS Goodies

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

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

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

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

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

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

ライブデモ

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

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

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

ソース

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

(function() {

  var win   = window,
      doc   = document,
      docEl = document.documentElement,
      body  = document.body;

  function isNumber(value) {
    return typeof value == 'number';
  }

  function isFunction(value) {
    return typeof value == 'function';
  }

  function bind(method, scope) {
    return function() { return method.apply(scope, arguments) };
  }

  function addEvent(el, type, proc, scope) {
    var listener = bind(proc, scope);
    el.addEventListener ?
      el.addEventListener(type, listener, false) :
      el.attachEvent('on' + type, listener);
    return { el: el, type: type, listener: listener };
  }

  function removeEvent(info) {
    info.el.addEventListener ?
      info.el.removeEventListener(info.type, info.listener, false) :
      info.el.detachEvent('on' + info.type, info.listener);
  }

  function stopEvent(ev) {
    ev.stopPropagation && ev.stopPropagation();
    ev.preventDefault  && ev.preventDefault();
    ev.cancelBubble = true;
    ev.returnValue  = false;
  }

  function getElement(id) {
    return typeof id === 'string' ? doc.getElementById(id) : id;
  }

  function getScrollStd() {
    return { x: docEl.scrollLeft, y: docEl.scrollTop };
  }
  function getScrollN4() {
    return { x: win.pageXOffset, y: win.pageYOffset };
  }
  function getScrollQuirks() {
    return { x: body.scrollLeft, y: body.scrollTop };
  }
  var getScroll =
    (isNumber(win.pageXOffset) ? getScrollN4 :
     (docEl && docEl.clientWidth ? getScrollStd : getScrollQuirks));

  function getWindowSizeN4() {
    return { x: win.innerWidth, y: win.innerHeight };
  }
  function getWindowSizeStd() {
    return { x: docEl.clientWidth, y: docEl.clientHeight };
  }
  function getWindowSizeQuirks() {
    return { x: body.offsetWidth, y: body.clientHeight };
  }
  var getWindowSize =
    (win.innerWidth ? getWindowSizeN4 :
     (docEl && docEl.clientWidth ? getWindowSizeStd : getWindowSizeQuirks));

  /** @constructor */
  var DragResize = function(container, options) {
    options   = options || {};
    container = getElement(container);

    this.container        = container;
    this.minWidth         = options['minWidth'];
    this.minHeight        = options['minHeight'];
    this.scroll           = options['scroll'];
    this.onClickCallback  = options['onclick'];
    this.onDragCallback   = options['ondrag'];
    this.onFinishCallback = options['onfinish'];
    this.callbackScope    = options['scope'];
    this.ignoreTags       = {};
    this.events           = [];

    var dragHandle   = getElement(options['dragHandle']),
        resizeHandle = getElement(options['resizeHandle']);
    if(dragHandle || resizeHandle != container) {
      this.dragHandle = dragHandle || container;
      this.events.push(addEvent(this.dragHandle, 'mousedown', this.drag_onMouseDown, this));
      this.events.push(addEvent(this.dragHandle, 'click',     this.onClick,          this));
    }
    if(this.dragHandle != container || (resizeHandle && resizeHandle != this.dragHandle)) {
      this.resizeHandle = resizeHandle || container;
      this.events.push(addEvent(this.resizeHandle, 'mousedown', this.resize_onMouseDown, this));
      this.events.push(addEvent(this.resizeHandle, 'click',     this.onClick,            this));
    }

    var ignoreTags = options['ignoreTags'];
    if(typeof ignoreTags === 'string') {
      ignoreTags = [ignoreTags];
    }
    if(ignoreTags) {
      for(var i = 0 ; i < ignoreTags.length ; ++i) {
        this.ignoreTags[ignoreTags[i].toUpperCase()] = true;
      }
    }
  };

  DragResize['minWidth']  = 100;
  DragResize['minHeight'] = 100;
  DragResize.dragInfo     = null;
  DragResize.ie           = !!win.ActiveXObject;

  DragResize.onMouseMove = function(ev) {
    var info = DragResize.dragInfo;
    if(info) {
      if(DragResize.ie &&
         !(document.documentMode && document.documentMode >= 8) &&
         !(ev.button & 1)) {
        DragResize.finish();
      } else {
        info.currentX = ev.clientX;
        info.currentY = ev.clientY;
        DragResize.ie && stopEvent(ev);
        var self = info.manager;
        if(self && isFunction(self.onDragCallback)) {
          self.onDragCallback.call(
            self.callbackScope, self.container, info.currentX, info.currentY);
        }
      }
    }
  };

  DragResize.onMouseUp = function(ev) {
    var info = DragResize.dragInfo, scroll = getScroll();
    try {
      if(info) {
        var self = info.manager;
        if(self && isFunction(self.onClickCallback) &&
           info.clickX + info.baseScX == ev.clientX + scroll.x &&
           info.clickY + info.baseScY == ev.clientY + scroll.y) {
          self.onClickCallback.call(self.callbackScope, ev);
        }
      }
    } finally {
      DragResize.finish();
    }
  };

  DragResize.finish = function() {
    var info = DragResize.dragInfo;
    DragResize.dragInfo = null;
    if(info) {
      info.intervalId && clearInterval(info.intervalId);
      var self = info.manager;
      if(self && isFunction(self.onFinishCallback)) {
        self.onFinishCallback.call(
          self.callbackScope, self.container, info.currentX, info.currentY);
      }
    }
  };

  DragResize.prototype.drag_onMouseDown = function(ev) {
    if(this.checkIgnoreTags(ev))
      return;
    stopEvent(ev);
    var info      = DragResize.dragInfo = this.beginDrag(ev, this.drag_onInterval),
        container = this.container;
    info.baseX = container.offsetLeft;
    info.baseY = container.offsetTop;
    info.minX  = 0;
    info.minY  = 0;
    info.maxX  = docEl.scrollWidth  - container.offsetWidth;
    info.maxY  = docEl.scrollHeight - container.offsetHeight;
  };

  DragResize.prototype.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.offsetWidth,
            bottom    = y + container.offsetHeight,
            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) && win.scroll(sx, sy);
      }
    });
  };

  DragResize.prototype.resize_onMouseDown = function(ev) {
    if(this.checkIgnoreTags(ev))
      return;
    stopEvent(ev);
    var info      = DragResize.dragInfo = this.beginDrag(ev, this.resize_onInterval),
        container = this.container,
        minWidth  = (isNumber(this.minWidth)  ? this.minWidth  : DragResize['minWidth']),
        minHeight = (isNumber(this.minHeight) ? this.minHeight : DragResize['minHeight']);
    info.baseX   = container.offsetLeft + container.offsetWidth;
    info.baseY   = container.offsetTop  + container.offsetHeight;
    info.adjustX = container.offsetWidth  - container.clientWidth;
    info.adjustY = container.offsetHeight - container.clientHeight;
    info.minX    = container.offsetLeft + info.adjustX + minWidth;
    info.minY    = container.offsetTop  + info.adjustY + minHeight;
    info.maxX    = docEl.scrollWidth;
    info.maxY    = container.style.position != 'absolute' ? 65536 : docEl.scrollHeight;
  };

  DragResize.prototype.resize_onInterval = function() {
    this.updateDrag(function(info, container, x, y, scroll) {
      container.style.width  = (x - container.offsetLeft - info.adjustX) + 'px';
      container.style.height = (y - container.offsetTop  - info.adjustY) + 'px';
      if(this.scroll) {
        var frameSize = getWindowSize(),
            left      = container.offsetLeft,
            top       = container.offsetTop,
            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) && win.scroll(sx, sy);
      }
    });
  };

  DragResize.prototype.onClick = function(ev) {
    if(!this.checkIgnoreTags(ev))
      stopEvent(ev);
  };

  DragResize.prototype.checkIgnoreTags = function(ev) {
    var el = ev.target || ev.srcElement;
    while(el) {
      if(el.nodeType == 1 && this.ignoreTags[el.tagName.toUpperCase()])
        return el;
      el = el.parentNode;
    }
    return null;
  };

  DragResize.prototype.beginDrag = function(ev, updateProc) {
    stopEvent(ev);
    DragResize.finish();
    var container = this.container,
        scroll    = getScroll(),
        mouseX    = ev.clientX,
        mouseY    = ev.clientY,
        scrollX   = scroll.x,
        scrollY   = scroll.y;
    return {
      manager:    this,
      intervalId: setInterval(bind(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
    };
  };

  DragResize.prototype.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;
    }
  };

  DragResize.prototype['detach'] = function() {
    if(DragResize.dragInfo && DragResize.dragInfo.manager == this)
      DragResize.finish();
    while(this.events.length > 0)
      removeEvent(this.events.pop());
    this.container = this.dragHandle = this.resizeHandle =
      this.onClickCallback = this.onDragCallback = this.onFinishCallback =
      this.callbackScope = this.ignoreTags = this.events = null;
  }

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

  win['DragResize'] = DragResize;

})();

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

http://webos-goodies.googlecode.com/svn/trunk/prod... (非圧縮版)
http://webos-goodies.googlecode.com/svn/trunk/prod... (Closure Compiler による圧縮版)

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

使い方

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

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

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

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

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

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

ignoreTags にタグ名( 'A' など)やその配列を指定すると、その種類のタグのクリックは処理を行いません。ライブデモのタイトルバーのリンクはこの機能を使っています。

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

コールバックの使い方

コンストラクタ・オプションの onclick, ondrag, onfinish には、それぞれのタイミングで実行する関数を指定できます。 onclick には引数としてmouseupイベントが、 ondragonfinish にはドラッグの対象要素、マウスの x/y 座標(クライント座標)が引数として渡されます。また、 scope オプションになんらかのオブジェクトを指定した場合、それぞれの関数呼び出し時の this オブジェクトとして渡されます。

new DragResize(element, { onclick:onClick, ondrag:onDrag, onfinish:onFinish });

function onClick(ev) {
  console.log('Click (' + ev.clientX + ', ' + ev.clientY ')');
}

function onDrag(element, x, y) {
  console.log('Drag (' + x + ', ' + y ')');
}

function onFinish(element, x, y) {
  console.log('Finish (' + x + ', ' + y ')');
}

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

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 では、テキスト上でドラッグを開始したときのみ、マウスカーソルがブラウザ外に出るとドラッグが止まってしまいます(謎)。

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

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

関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles