はじめてのGoogle Chrome拡張、オレオレChromeKeyconfig

今週からようやく MacChromium (最新版)で、拡張が動くようになったので、さっそく簡単な拡張を作ってみた。

と言っても id:os0x さんの ChromeKeyconfig ↓を改造して足りない機能を付けただけだけど。


Google Chrome の拡張は、Chrome 自身のコマンドラインオプションで作ることになっているらしいのだけど、これがまだ MacChromium では動かない。

なので、とりあえず上から ChromeKeyconfig.crx をインストールして、インストール先のファイルを弄る方針でやった。

Mac なら /Users/ユーザー名/Library/Application Support/Chromium/Default/Extensions/ あたりを漁れば見つかる。


インストール先がわかったら、その中にある chrome_keyconfig.js を、このファイルで置き換える。

// Chrome Keyconfig
// license http://0-oo.net/pryn/MIT_license.txt (The MIT license)

(function(){
  var connection = chrome.extension.connect();
  var action_config = {
    ";" : "left_tab",
    "'" : "right_tab",
    "j" : "scroll_down",
    "k" : "scroll_up",
    "h" : "scroll_left",
    "l" : "scroll_right",
    "M-j" : "scroll_down_half_page",
    "M-k" : "scroll_up_half_page",
    "M-J" : "scroll_to_bottom",
    "M-K" : "scroll_to_top",
    "C-c" : "blur_focus",
    "," : "back",
    "." : "forward",
    "a" : "focus_first_text_input",
    "C-d" : "navigate_form_elements_forward",
    "C-u" : "navigate_form_elements_backward",
    "f" : "hit_a_hint",
  };

  var Action = {
    go_parent_dir : function(){
      var paths = location.pathname.split('/');
      var last = paths.pop();
      location.pathname = (last ? paths : paths.slice(0,paths.length-1)).join('/');
    },
    back : function(){
      history.back();
    },
    forward : function(){
      history.forward();
    },
    open_tab : function(){
      connection.postMessage({message:'action',values:{type:'open_tab'}});
    },
    close_tab : function(){
      connection.postMessage({message:'action',values:{type:'close_tab'}});
    },
    right_tab : function(){
      connection.postMessage({message:'action',values:{type:'right_tab'}});
    },
    left_tab : function(){
      connection.postMessage({message:'action',values:{type:'left_tab'}});
    },
    far_right_tab : function(){
      connection.postMessage({message:'action',values:{type:'far_right_tab'}});
    },
    far_left_tab : function(){
      connection.postMessage({message:'action',values:{type:'far_left_tab'}});
    },
    scroll_down : function(){
      window.scrollBy(0,50);
    },
    scroll_up : function(){
      window.scrollBy(0,-50);
    },
    scroll_right : function(){
      window.scrollBy(50,0);
    },
    scroll_left : function(){
      window.scrollBy(-50,0);
    },
    scroll_down_half_page : function(){
      window.scrollBy(0,window.innerHeight/2);
    },
    scroll_up_half_page : function(){
      window.scrollBy(0,-window.innerHeight/2);
    },
    scroll_to_top : function(){
      window.scrollBy(0,-document.documentElement.scrollHeight);
    },
    scroll_to_bottom : function(){
      window.scrollBy(0,document.documentElement.scrollHeight);
    },
    blur_focus : function(){
      document.activeElement.blur();
    },
    focus_first_text_input : function(){
      var elem = document.querySelector('input[type="text"],input:not([type])');
      if (elem) elem.focus();
    },
    navigate_form_elements_forward : function(){
      var elems = document.querySelectorAll('input:not([type="hidden"]),textarea,button,select');
      var len = elems.length;
      if (!len) return;
      var cur = document.activeElement;
      function focus_next(i){
        var j = 0;
        while (i+(++j) < len) {
          elems[i+j].focus();
          if (document.activeElement === elems[i+j]) return true;
        }
      }
      Array.prototype.some.call(elems,function(elem,i){
        if (cur === elem) return focus_next(i);
      }) || focus_next(-1);
    },
    navigate_form_elements_backward : function(){
      var elems = document.querySelectorAll('input:not([type="hidden"]),textarea,button,select');
      var len = elems.length;
      if (!len) return;
      var cur = document.activeElement;
      function focus_prev(i){
        var j = 0;
        while (i-(++j) >= 0){
          elems[i-j].focus();
          if (document.activeElement === elems[i-j]) return true;
        }
      }
      Array.prototype.some.call(elems,function(elem,i){
        if (cur === elem) return focus_prev(i);
      }) || focus_prev(len);
    },
    hit_a_hint : function(){
      var hintkeys = 'asdfghjkl';
      function createText(num) {
        var text = '', l = hintkeys.length, iter = 0;
        while (num >= 0) {
          var n = num;
          num -= Math.pow(l, 1 + iter++);
        }
        for (var i = 0; i < iter; i++) {
          r = n % l;
          n = Math.floor(n / l);
          text = hintkeys.charAt(r) + text;
        }
        return text;
      };
      function retrieveNumber(text) {
        text += '';
        for (var i=0,n=0,l=text.length; i<l; i++) {
          var fix = (i == 0) ? 0 : 1;
          var t = text.charAt(l - i - 1);
          n += (hintkeys.indexOf(t) + fix) * Math.pow(hintkeys.length, i);
        }
        return n;
      };
      var html = document.documentElement;
      var body = document.body;
      function getAbsolutePosition(elem) {
        var style = getComputedStyle(elem, null);
        var rect = elem.getBoundingClientRect();
        if (rect && rect.right - rect.left > 0 && 
          style.visibility != 'hidden' && style.opacity != 0 && 
          rect.left >= 0 && rect.right <= window.innerWidth &&
          rect.top >= -5 && rect.bottom <= window.innerHeight + 5) {
            return {
              y: (body.scrollTop || html.scrollTop) - html.clientTop + rect.top,
              x: (body.scrollLeft || html.scrollLeft) - html.clientLeft + rect.left
            }
        }
        return false;
      };
      var hints = [];
      function drawHints() {
        var elems = document.querySelectorAll('a[href],*[onclick],input:not([type="hidden"]),textarea,button,select');
        var df = document.createDocumentFragment();
        var count = 0;
        Array.prototype.forEach.call(elems,function(elem){
          var pos = getAbsolutePosition(elem);
          if (pos) {
            var span = document.createElement('span');
            span.setAttribute('style',[
              'font-size:10pt;',
              'padding:0pt 1pt;',
              'margin:0;',
              'line-height:10pt;',
              'position:absolute;',
              'z-index:2147483647;',
              'opacity:.7;',
              'color:#000;',
              'background-color:#FF0;',
              'left:', Math.max(0,pos.x-8), 'px;',
              'top:', Math.max(0,pos.y-8), 'px;',
            ].join(''));
            span.textContent = createText(count++);
            df.appendChild(span);
            hints.push({
              elem : elem,
              label : span,
              text : span.textContent,
            });
          }
        });
        if (!hints.length) return;
        var div = document.createElement('div');
        div.setAttribute('id', 'HaH-div-element');
        div.appendChild(df);
        document.body.appendChild(div);
      };
      var choice = '', choiceHint;
      function pushLetter(key){
        var hint = hints[retrieveNumber(choice+key)];
        if (hint){
          choice += key;
          var lastHint = hints[hints.length - 1].text;
          hint.label.style.backgroundColor = '#F0F';
          if (choiceHint) choiceHint.label.style.backgroundColor = '#FF0';
          choiceHint = hint;
        }
      };
      function focusHint(){
        setTimeout(function(){choiceHint.elem.focus();},0);
        unloadHaH();
      };
      function unloadHaH(key){ 
        if (choiceHint) choiceHint.label.style.backgroundColor = '#FF0';
        choice = '';
        var div = document.getElementById('HaH-div-element');
        if (div) div.parentNode.removeChild(div);
        document.removeEventListener('keydown',handler,false);
        keyconfig.init();
      };
      function handler(e){
        var key = get_key(e);
        if (hintkeys.indexOf(key) >= 0) return pushLetter(key);
        if (key == ';') return focusHint();
        unloadHaH();
      };
      function initHaH(){
        drawHints();
        document.addEventListener('keydown',handler,false);
        keyconfig.unload();
      };
      initHaH();
    },
  };
  var keyconfig = {
    init : function(){
      document.addEventListener("keydown", keyconfig, false);
    },
    unload : function(){
      document.removeEventListener("keydown", keyconfig, false);
    },
    handleEvent : function(e){
      var key = get_key(e);
      var ele = e.target;
      if (!/(C-|M-)/.test(key) &&
          (/^textarea$/i.test(ele) ||
          (/^input$/i.test(ele.nodeName) && 
           /^(text|password)$/i.test(ele.type)))) {
        return;
      }
      if(keyconfig.fireAction(key) !== false){
        e.preventDefault();
      }
    },
    fireAction : function(key){
      var action = Action[action_config[key]];
      if (typeof action === 'function') action();
      else return false;
    },
  };

  var keyId = {
    "U+0008" : "BackSpace",
    "U+0009" : "Tab",
    "U+0018" : "Cancel",
    "U+001B" : "Esc",
    "U+0020" : "Space",
    "U+0021" : "!",
    "U+0022" : "\"",
    "U+0023" : "#",
    "U+0024" : "$",
    "U+0026" : "&",
    "U+0027" : "'",
    "U+0028" : "(",
    "U+0029" : ")",
    "U+002A" : "*",
    "U+002B" : "+",
    "U+002C" : ",",
    "U+002D" : "-",
    "U+002E" : ".",
    "U+002F" : "/",
    "U+0030" : "0",
    "U+0031" : "1",
    "U+0032" : "2",
    "U+0033" : "3",
    "U+0034" : "4",
    "U+0035" : "5",
    "U+0036" : "6",
    "U+0037" : "7",
    "U+0038" : "8",
    "U+0039" : "9",
    "U+003A" : ":",
    "U+003B" : ";",
    "U+003C" : "<",
    "U+003D" : "=",
    "U+003E" : ">",
    "U+003F" : "?",
    "U+0040" : "@",
    "U+0041" : "a",
    "U+0042" : "b",
    "U+0043" : "c",
    "U+0044" : "d",
    "U+0045" : "e",
    "U+0046" : "f",
    "U+0047" : "g",
    "U+0048" : "h",
    "U+0049" : "i",
    "U+004A" : "j",
    "U+004B" : "k",
    "U+004C" : "l",
    "U+004D" : "m",
    "U+004E" : "n",
    "U+004F" : "o",
    "U+0050" : "p",
    "U+0051" : "q",
    "U+0052" : "r",
    "U+0053" : "s",
    "U+0054" : "t",
    "U+0055" : "u",
    "U+0056" : "v",
    "U+0057" : "w",
    "U+0058" : "x",
    "U+0059" : "y",
    "U+005A" : "z",
    "U+005B" : "[",
    "U+005C" : "\\",
    "U+005D" : "]",
    "U+005E" : "^",
    "U+005F" : "_",
    "U+0060" : "`",
    "U+007B" : "{",
    "U+007C" : "|",
    "U+007D" : "}",
    "U+007F" : "Delete",
/* unsupported  
    "U+00A1" : "RevExcl",
    "U+0300" : "CombGrave",
    "U+0300" : "CombAcute",
    "U+0302" : "CombCircum",
    "U+0303" : "CombTilde",
    "U+0304" : "CombMacron",
    "U+0306" : "CombBreve",
    "U+0307" : "CombDot",
    "U+0308" : "CombDiaer",
    "U+030A" : "CombRing",
    "U+030B" : "CombDblAcute",
    "U+030C" : "CombCaron",
    "U+0327" : "CombCedilla",
    "U+0328" : "CombOgonek",
    "U+0345" : "CombYpogeg",
    "U+20AC" : "Euro",
    "U+3099" : "CombVoice",
    "U+309A" : "CombSVoice",
*/
  }

  function get_key(evt){
    var key = keyId[evt.keyIdentifier] || evt.keyIdentifier,
    ctrl = evt.ctrlKey ? 'C-' : '',
    meta = (evt.metaKey || evt.altKey) ? 'M-' : '',
    shift = evt.shiftKey ? 'S-' : '';
    if (/^(Meta|Shift|Control|Alt)$/.test(key)) return null; // safari only
    if (evt.shiftKey){
      if (/^[a-z]$/.test(key)) 
        return ctrl+meta+key.toUpperCase();
      if (/^(Enter|Space|BackSpace|Tab|Esc|Home|End|Left|Right|Up|Down|PageUp|PageDown|Delete|F\d\d?)$/.test(key)) 
        return ctrl+meta+shift+key;
    }
    return ctrl+meta+key;
  }

  keyconfig.init();
  /*
  var prefs = {
    init : function(){
      connection.postMessage({
        message: 'prefs_load', 
        values: {
          action: Action,
          action_config: action_config,
        }
      });
      connection.onMessage.addListener(function(data){
        if (data.message === 'prefs_reload_request') 
          prefs.reload(data.values)
      });
    },
    reload : function(pref){
      Action = pref.action;
      action_config = pref.action_config;
    }
  };
  prefs.init();
  */
})();

一番上の部分を見たら大体コマンドが分かると思うけど、jk でスクロール、Command キーと組み合わせて大きくスクロール、Ctrl+du でフォーム部品だけ巡る (chromium はタブキーでアンカーまで含めて移動するので)、などなど。Hit-a-Hintブックマークレットも移植した。「asdfghjkl」で選択、「;」で決定 (フォーカス)。

キーコードまわりは keyIdentifier のおかげでかなりラクができている。WebKit エラい。


次に background.html を、このファイルで置き換える。(と言ってもこっちはほとんど変えてないけど)

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ChromeKeyConfig Pref Page</title>
<script type="text/javascript">
window.onload = function(){
  chrome.self.onConnect.addListener(function(port) {
    port.onMessage.addListener(function(data,con){
      if (data.message !== 'action') return;
      chrome.windows.getCurrent(function (_window) {
        chrome.tabs.getSelected(_window.id,function(tab){
          var type = data.values.type;
          switch(type){
          case "open_tab":
            chrome.tabs.create({'windowId':_window.id,'index':tab.index+1,'url':'chrome://newtab/','selected':true});
            break;
          case "close_tab":
            chrome.tabs.remove(tab.id);
            break;
          case "right_tab":
          case "left_tab":
          case "far_right_tab":
          case "far_left_tab":
            chrome.windows.getAll(true, function(windowList){
              windowList.forEach(function(_w){
                if (_window.id == _w.id) {
                  _w.tabs.forEach(function(_t,i,tabs){
                    if (_t.id === tab.id){
                      var newtab;
                      switch(type){
                      case "right_tab":
                        newtab = tabs[i+1] || tabs[0]; break;
                      case "left_tab":
                        newtab = tabs[i-1] || tabs[tabs.length-1]; break;
                      case "far_right_tab":
                        newtab = tabs[tabs.length-1]; break;
                      case "far_left_tab":
                        newtab = tabs[0]; break;
                      }
                      if (newtab){
                        chrome.tabs.update(newtab.id, {selected:true});
                      }
                    }
                  });
                }
              });
            });
            break;
          }
        });
      });
    });
  });
};
</script>
<style type="text/css">
</style>
<body>
</body>
</html>

ここでようやく Chromium を起動。

あれこれ

  • まだ激しくアルファ版なので (Chromium もこの Keyconfig も)、とりあえず動くレベルでしか作ってない。
  • 将来的にはこの background-page が各拡張の設定画面になるのかな? という予想に基いて弄ろうと思ったけど面倒だったので放置。
  • 自分が弄った部分は UserScript の粋を出ないので、まだ Chrome 拡張のことについてはよくわかっていない。
  • 拡張間の連携がかなり難しいと思った。
    • 最初、この Keyconfig では低レベルレイヤーだけ作って、上のレイヤーを他の拡張で実現しようと思ったけど、イベントオブジェクトを渡そうと思ったらどうしてもグローバルに出さないといけないよね。
    • 現在思案中。
  • 起動時に開くページを指定すると、起動時に瞬時に開くのだけど、たまに拡張が動いてないときがある。リロードすると動く。
    • これが「起動を速く見せる表現」というやつか。
    • 動いてるはずのものが動いてないのは気分が悪いのでやめてほしい。