クライアントサイドWeb付箋紙 Greasemonkey

グリモンでこんなの作ってみました。




図1: はてなのトップページに付箋紙をぺたぺた貼り付けてみた

これは何?

Greasemonkeyを使ったWeb付箋紙ツールです。
Web上の任意のページにメモを貼り付けておくことができます。
GreasemonkeyのGM_getValue/GM_setValueを使ってデータをクライアントサイドに保存します。
そのためログイン不要で使えますけど、複数端末で共有したり、他の人に見せてあげたりはできません。

作成動機

まるごとJavaScript & Ajax ! Vol.1に載っていたFirefox2.0のクライアントサイドストレージを使って何かできないかなと思って作りました。最初、普通の<script>タグでスクリプトを読み込まして作って*1ちゃんと動いたのですが、Greasemonkeyのuser.jsとして動かしたら、なぜかクライアントサイドストレージが読み込めず*2、GM_setValue/GM_getValueというのを知って、こっちを使うように切り替えました。

インストール

http://sawat.jf.land.to/gm/GmSticky.user.jsをインストール

使い方

  • 任意のページで Altキーを押しながら @キーを押すと新しい付箋紙を貼り付けることができます。
    • 表示されたダイアログに内容を記入します。(図2)
    • 初期値は現在の日時です。*3
    • "<"からはじめるとHTMLタグが有効になります。
    • HTMLタグを使わない場合は、\nで改行できます。



図2: 新規作成
  • 貼り付けた付箋紙をダブルクリックすると編集できます。(図3)
    • "<"からはじめるとHTMLタグが有効になります。
    • HTMLタグを使わない場合は、\nで改行できます。
    • 空にして「OK」すると付箋紙が削除されます。
    • REMOVE_ALLと記入すると、そのページの付箋紙を全て削除します。



図3: 編集
  • 貼り付けた付箋紙はマウスでドラッグして移動させることができます。
    • 貼り付けた付箋紙をShiftキーを押しながらクリックすると削除できます。
    • 貼り付けた付箋紙をCtrlキーを押しながらクリックすると複製できます。
  • 付箋紙はページごと*4に管理されます。削除しない限り残ります。
    • 二度と行かないページや、ランダム値やセッションIDを含むURLのページには貼らないように。
    • about:configで'sticky'でフィルタを掛ければ付箋紙のデータが見れます。
      • 痕跡を完全に削除するには、表示された項目を「リセット」してください。(追記 2/28)(下の追記も参照してください)

はまったところなど

  • クライアントサイドストレージには文字列しか保存できない。
    • JSON形式で保存すればよい。
  • グリモンからはクライアントサイドストレージにアクセスできない。
    • 代わりにGM_setValue/GM_getValueを使って解決。
  • GM_setValue/GM_getValueで日本語を入れると文字化けする。
    • encodeURIとdecodeURIで解決。

やろうかと思ったけどやらなかった機能

  • prompt関数じゃない入力フォーム
    • 面倒臭いので却下。
  • 編集ボタン・削除ボタン・横幅変更ボタン
    • 面倒臭いし、無くても問題ないので却下。
  • 付箋紙の有効スコープの複数化 (同一ドメイン内で常に表示など)
    • 多分、使い道が無いので却下。
  • 同一ドメイン内の付箋紙を一括削除
    • クライアントサイドストレージを使ってた頃はできたけど、GM_setValue/GM_getValueに変更してやり辛くなったので却下。
  • Firefox以外のブラウザへの対応

ソース

// ==UserScript==
// @name          GmSticky
// @namespace     http://d.hatena.ne.jp/sawat/
// @description   Make stikies and save at greasemonkey storage.
// @include       http://*
// @include       https://*
// ==/UserScript==
if(window.Sticky == void(0)) {
(function() {
  
  var Util = {
    document: window.document,
    // DOM構築関連
    createElement: function(tagName, parent, style, attributes, listeners, innerHTML) {
      var element = Util.document.createElement(tagName);
      if(attributes) {Util.setAttributes(element, attributes); }
      if(parent) {parent.appendChild(element);}
      if(style) {Util.setStyle(element, style); }
      if(innerHTML) {element.innerHTML = innerHTML;}
      if(listeners) {Util.setListeners(element, listeners); }
      return element;
    },
    setAttributes : function(element, attributes) {
      for(var prop in attributes) {
        element.setAttribute(prop, attributes[prop]);
      }
    },
    setListeners : function(element, listeners) {
      var func = element.addEventListener ? function(name, listener) { element.addEventListener(name, listener, false); }
                                          : function(name, listener) { element.attachEvent('on' + name, listener); } ;
      for(var prop in listeners) {
        func(prop, listeners[prop]);
      }
    },
    setStyle : function(element, style) {
      Util.marge(element.style, style);
    },
    marge : function(dest, src) {
      for(var prop in src) {
        dest[prop] = src[prop];
      }
    },
    escapeHtmlChar : function(str) {
      return str && str.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\\n/g, '<br />');
    }
  };
 
 
 
  /** Sticky コンストラクタ. */
  window.Sticky = function Sticky(text, x, y) {
    this.text = text;
   
    this.div = Util.createElement('div',
                   document.body,
                   Sticky.STYLE,
                   null,
                   this.createListeners(),
                   null);
   
    this.clickX = 0;
    this.clickY = 0;
   
    this.edit(text);
    this.setPosition(x || document.body.scrollLeft + Math.floor(document.body.clientWidth/2),
                     y || document.body.scrollTop  + Math.floor(document.body.clientHeight/2));
  };
 
  /** インスタンスのメソッド */
  window.Sticky.prototype = {
    /** 編集 */
    edit: function(newText) {
      newText = newText || prompt(Sticky.EDIT_MESSAGE, this.text);
      // キャンセル
      if(newText == void(0)) return;
      // 削除
      if(newText == '') {
        this.dispose();
        return;
      }
      // ページ内削除
      if(newText == 'REMOVE_ALL') {
        Sticky.removeAll();
        return;
      }
      // ドメイン内削除
      if(newText == '!REMOVE_ALL') {
        Sticky.removeAllInDomain();
        return;
      }

      var use_html = newText.match(/^</) != null;
      try {
        this.div.innerHTML = use_html ? newText : Util.escapeHtmlChar(newText);
      } catch(e) {
        this.div.innerHTML = Util.escapeHtmlChar(newText);
      }
      this.text = newText;
      Sticky.store();
    },
    /** 破棄 */
    dispose : function(flag) {
      this.div.parentNode.removeChild(this.div);
      if(!flag) Sticky.remove(this);
    },
    /** 配置 */
    setPosition : function(x, y) {
      this.div.style.left = Math.max(10,
                            Math.min(document.body.scrollWidth - this.div.clientWidth - 10,
                                     x - this.clickX)) + "px";
      this.div.style.top = Math.max(10,
                           Math.min(document.body.scrollHeight - this.div.clientHeight - 10,
                                     y - this.clickY)) + "px";
    },
    /** コピー */
    copy : function () {
      return Sticky.create(this.text, parseInt(this.div.style.left)+10, parseInt(this.div.style.top)+10);
    },
    /** 個別の付箋紙(div)に対するリスナー */
    createListeners : function() {
      var sticky = this;
      return {
        /** ダブルクリック : 編集 */
        'dblclick' : function(event) {
          sticky.edit();
          Sticky.moving = null;
        },
        /** マウスダウン : ドラッグ開始 */
        'mousedown': function(event) {
          Sticky.moving = sticky ;
          sticky.clickX = event.pageX - parseInt(sticky.div.style.left);
          sticky.clickY = event.pageY - parseInt(sticky.div.style.top);
        },
        /** Shift+クリック : 削除
            Ctrl+クリック  : コピー */
        'click' : function (event) {
          if(event.shiftKey) sticky.dispose();
          if(event.ctrlKey) sticky.copy();
        }
      };
    },    
    /** 文字列化(JSON) */
    toString: function() {
      return '{ x:' + parseInt(this.div.style.left) +
              ',y:' + parseInt(this.div.style.top)  +
              ',text:"' + this.text.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"' +  // "
              '}';
    }
  }
 
  /** クラスのメソッド・定数(?) */
  Util.marge(window.Sticky, {
    _storage: [],
    /** 新規作成 */
    create: function(text, x, y, use_html) {
      text = text || prompt('New Sticky', document.getSelection() || new Date().toLocaleString());
      if(!text) return;
      Sticky.add(new Sticky(text, x, y, use_html));
      Sticky.store();
    },
    /** 登録 */
    add: function(newSticky) {
      Sticky._storage.push(newSticky);
    },
    /** 削除 */
    remove: function(sticky) {
      for(var i=0,n=Sticky._storage.length; i<n; i++) {
        if(Sticky._storage[i] == sticky) {
          Sticky._storage.splice(i, 1);
          break;
        }
      }
      Sticky.store();
    },
    /** ページ内全削除 */
    removeAll: function(sticky) {
      for(var i=0,n=Sticky._storage.length; i<n; i++) {
        Sticky._storage[i].dispose();
      }
      Sticky._storage = [];
      Sticky.store();
    },
    /** ドメイン内全削除 */
    removeAllInDomain: function(sticky) {
      Sticky.removeAll();
      return;
    },
    /** クライアントサイドストレージへの保存キー作成 */
    key : function() {
      return '_stickies@' + document.location.host + document.location.pathname + document.location.search;
    },
    /** クライアントサイドストレージからの読み込み */
    load : function() {
      var data = GM_getValue(Sticky.key());
      if(!data) return;
      try {
        var stickies = eval(decodeURI(data));
        for(var i=0; i<stickies.length; i++) {
          Sticky.create(stickies[i].text, stickies[i].x, stickies[i].y);
        }
      } catch(e) {
        new Sticky('<span style="color:red;font-weight:bold">Error on load stickise data.</span>', 1, 1);
      }
    },
    /** クライアントサイドストレージへの書き込み */
    store : function() {
      var data = '[' + Sticky._storage.join(',') + ']';
      GM_setValue(Sticky.key(), encodeURI(data));
    },
    /** 初期化 */
    initialize : function () {
      Util.setListeners(document.getElementsByTagName('html')[0], Sticky.GLOBAL_LISTENER);
      setTimeout(Sticky.load, 500);
    },
    /** 付箋紙のスタイル */
    STYLE : {
      position: 'absolute',
      left: '0px',
      top: '0px',
      cursor: 'move',
      minimumWidth: '100px',
      borderColor: '#663333',
      borderWidth: '1px 2px 2px 1px',
      borderStyle: 'solid',
      backgroundColor: '#FFFFEF',
      fontSize: '90%'
    },
    /** document全体に対するリスナー */
    GLOBAL_LISTENER : {
      /** マウスムーブ : ドラッグ */
      'mousemove' : function (event) {
        if (!Sticky.moving) return;
        Sticky.moving.setPosition(event.pageX, event.pageY);
      },
      /** マウスアップ : ドラッグ終了 */
      'mouseup' : function (event) {
        if (!Sticky.moving) return;
        Sticky.moving.clickX = 0;
        Sticky.moving.clickY = 0;
        Sticky.moving = null;
        Sticky.store();
      },
      /** 新規作成 */
      'keydown' : function (event) {
        if (event.altKey && event.keyCode == 192 /* @キー */) {
          Sticky.create();
        }
      }
    },
    /** 編集ダイアログメッセージ */
    EDIT_MESSAGE : 
      'Edit\n'+
      '    Start with "<" : Use HTML tags.\n' +
      '    Empty : Remove this sticky\n' +
      '    "REMOVE_ALL" : Remove all stickies in this page.\n',
    VERSION : '0.01'
  });
  Sticky.initialize();
})();
}

追記(2/28)

GM_setValueで設定した値を削除するための関数がGreasemonkeyにないため、いろんなページに付箋を貼ったり消したりしていると次第にabout:configの設定が増えてしまいます。そのため、時々 about:config を開いて sticky でフィルタを掛けて出てくる項目をリセットすることをオススメします(図4)。
リセットされた項目はFirefoxを再起動すると完全に消去されます。ただし、再起動前に設定を削除したページを表示するとGM_getValueに失敗してエラーが発生します。
Firefoxが起動していないときに prefs.js *5を編集して削除してもかまいません。

(参考) GM_setValueで設定した内容の削除方法 - Enjoy*Study



図4: 設定をリセット

*1:user.jsだとFirebugが使えないので…。

*2:小細工をすれば読み込めることはわかったのですが、GM_setValue/GM_getValueに比べてメリットが無かったので。

*3:日時のフォーマットがアホなのはDate.prototype.toLocaleStringのせいです。年月日が漢字で、時分秒がコロンて・・・。

*4:ホスト名とパスとクエリー文字列で識別します。

*5:prefs.jsのありかは http://www.mozilla-japan.org/support/firefox/edit#profile を参照