Opera版Hit-a-Hintブックマークレットをより便利にするために動き出した

一連の記事で扱ってきたHit-a-Hintブックマーク版だったが、どうも少し使いにくい部分があったので、全面的 (というか半面的) に書き直した。

ちょっと気に入らないところがあるので、一応ベータ版としておきますが、キーボードでブラウジングするのが好きな人は使ってみると良いです。

実際のコードは長いので下のほうに載せるとして、説明を少し。

変更点

  • 前のコードでは、ヒントを付ける a 要素と、ヒントそのものである span 要素を別々の配列として管理していた。ところが、自分が任意のキーをヒントにできるようにしたため、a 要素は1からの数字で管理、span 要素はヒント文字列で管理、というごちゃごちゃ状態になっていた。それを Hint という一つの配列にまとめてみた。
    • このため、Blazeix さんのオリジナルのコードとはかなり異なってきている。
  • retrieveNumber 関数を書き換えた。これによって、前のコードではヒントの最大桁数を自分で指定する必要があったが、それを要らなくした。(前の関数はバグっぽいところがあったので、既に持ってる人はアップグレードしてみてもいいかも)
  • ヒントが一意に定まった時点で自動的に選択してくれるようにした。
  • 前のコードでは a 要素に appendChild で span 要素をくっつけていたので、ヒント描写時にレイアウトが少し崩れたり、ヒントを消したときに完全に消えなかったりしたところを、body に直接 appendChild するようにしたので、レイアウトの崩れはないと思う。
  • href で飛ぶリンクだけじゃなくて、onclick なリンクにも対応した。いろいろ方法を考えたが、その要素に dicpatchEvent で click イベントを投げることにした。これが結構上手くいった。(アイデアは下から得ました)
    • ホイールでセレクトボックスの値を変える - ┐(´ー`)┌なJavaScript雑記
    • 最初は getAttribute('href') して、this という文字列を replace してから eval しようかと思ってたんだけど、これだと巧くいかないのは分かっていたので、ちょっと躊躇っていた。
    • 次に考えたのが、call 関数を使って、href の中にある関数にヒントがコールされた要素 this として呼び出す方法だったのだが、これだと、onclick をその a 要素の親要素に付けてある場合は動かなかった。残念。
  • キーバインドは、今のところ、asdfghjkl でヒント指定、Space でヒントを決めて、Enter で選択したヒントをすべて開く。Ctrl+Enter だと新しいタブで開く。Ctrl+Shift+Enter も使えるよ。新しく増やしたのが、セミコロン ";" で一番最初に選択したヒントに click イベントを送る (または入力欄だったら focusする)、ということになっている。
    • つまり、通常のクリックをエミュレートしていると言ったらわかりやすいかな。
    • 「普通のリンク」ではない要素、つまり onclick なリンクと入力欄やボタン等は水色でマーキングするようにした。
  • ヒント表示時にやることが多くなったため、ヒント描画が遅くなりました。これは後で出来るだけ高速化を企るつもりです。

不具合 (かも?)

  • Twitter のモバイル版 (m.twitter.com) でヒントが出ない。
    • 最後の最後まで出てたのにぃ・・・
  • ";" キーで選択しても何も起こらないリンクがある。
    • 例えば YouTube の Related Videos の表示方法を切り替える、下の右端のボタンとか。(想像だが、a 要素の中に img 要素があって、それに onclick ハンドラが付いている?)

ブックマークレット

とりあえずベータ版だということと、読みやすさのために整形して書いた。
そのままでコピーして実行してもいいけど、bookmarklet maker に入れると改行とかを消してくれるのでいいよ。
Opera なら pre 要素内を4回クリックすると全選択してくれるのでコピペがラクだよ。

Opera 9.6 RC1 で動作確認。

javascript: (function() {
  var hintkeys = 'asdfghjkl';
  var bgcolor = '#FF0';
  var xbgcolor = '#0FF';
  var bghighlight = '#0F0';
  var color = '#000';
  var Hints = new Array();
  var choices = new Array();
  var choice = '';
  var choicenumber;
  var keycodemapping = {
    '13': 'Enter',
    '8': 'Bkspc',
    '32': '\x20',
    '59': ';',
    '16': '',
    '17': ''
  };
  for (var i = 0; i < hintkeys.length; i++) {
    var ithkey = hintkeys[i];
    keycodemapping[ithkey.charCodeAt(0)] = ithkey;
  }
  var originalTitle = document.title;

  function createText(num) {
    var ret = '';
    var l = hintkeys.length;
    var iter = 0;
    var n;
    while (num >= 0) {
      n = num;
      num -= l * Math.pow(l, iter);
      iter++;
    }
    for (var i = 0; i < iter; i++) {
      r = n % l;
      n = Math.floor(n / l);
      ret = hintkeys.charAt(r) + ret;
    }
    return ret;
  }
  function retrieveNumber(w) {
    var ret = 0;
    var wlen = w.length;
    for (var i = 0; i < wlen; i++) {
      var fix = (i == 0) ? 0 : 1;
      var t = w.charAt(wlen - i - 1);
      ret += (hintkeys.indexOf(t) + fix) * Math.pow(hintkeys.length, i);
    }
    return ret;
  }
  function getElementsByTagNames(target) {
    var XPath = '//' + target.split(',').join('|//');
    var xRes = document.evaluate(XPath, document, null, 7, null);
    var result = new Array();
    for (var i = 0; i < xRes.snapshotLength; i++) {
      result.push(xRes.snapshotItem(i));
    }
    return result;
  }

  function drawHints() {
    var allElements = getElementsByTagNames('a,input,textarea,select');
    var viewportStart = window.pageYOffset - 5;
    var viewportEnd = viewportStart + window.innerHeight + 10;
    var viewportLeft = window.pageXOffset - 10;
    var viewportRight = viewportLeft + window.innerWidth;
    var hintnumber = 0;
    for (var i = 0; i < allElements.length; i++) {
      var ele = allElements[i];
      var linkXcoord = getAbsoluteX(ele);
      var linkYcoord = getAbsoluteY(ele);
      if (linkYcoord > viewportStart && linkYcoord < viewportEnd && linkXcoord > viewportLeft && linkXcoord < viewportRight && getVisibility(ele)) {
        if (ele.tagName == 'TEXTAREA' || ele.tagName == 'SELECT' || ele.tagName == 'INPUT' && ele.type == 'text') {
          Hints.push({
            'element': ele,
            'type': 'text',
            'bg': xbgcolor,
            'hinttext': createText(hintnumber)
          });
        } else if (ele.tagName == 'INPUT' || ele.href && ele.getAttribute('href').match(/^#$|^javascript:void\(0\);?$/)) {
          Hints.push({
            'element': ele,
            'type': 'xlink',
            'bg': xbgcolor,
            'hinttext': createText(hintnumber)
          });
        } else {
          Hints.push({
            'element': ele,
            'type': 'link',
            'bg': bgcolor,
            'hinttext': createText(hintnumber)
          });
        }
        hintnumber++;
      }
    }
    if (Hints.length == 0) {
      return;
    }
    document.addEventListener('keypress', interpretKeyStroke, true);
    document.title += '\x20-\x20';

    for (var i = 0; i < Hints.length; i++) {
      var hint = document.createElement('span');
      hint.style.backgroundColor = Hints[i].bg;
      hint.style.color = color;
      hint.style.fontSize = '9pt';
      hint.style.padding = '0pt \x20 1.5pt';
      hint.style.margin = '0';
      hint.style.position = 'absolute';
      hint.style.zIndex = '10000';
      hint.style.opacity = '.7';
      hint.style.left = Math.max(0, getAbsoluteX(Hints[i].element) - 10);
      hint.style.top = Math.max(0, getAbsoluteY(Hints[i].element) - 10);
      hint.innerHTML = Hints[i].hinttext;
      Hints[i].hintnode = hint;
      document.body.appendChild(hint);
    }
  }
  function getAbsoluteY(element) {
    var y = 0;
    while (element) {
      y += element.offsetTop;
      element = element.offsetParent;
    }
    return y;
  }
  function getAbsoluteX(element) {
    var x = 0;
    while (element) {
      x += element.offsetLeft;
      element = element.offsetParent;
    }
    return x;
  }
  function getVisibility(element) {
    var style = getComputedStyle(element, '');
    if (style.display == 'none') {
      return false;
    } else {
      while (element && element != document) {
        style = getComputedStyle(element, '');
        if (style.visibility == 'visible') {
          element = element.parentNode;
        } else {
          return false;
        }
      }
      return true;
    }
  }
  function removeHints() {
    for (var i = 0; i < Hints.length; i++) {
      Hints[i].hintnode.parentNode.removeChild(Hints[i].hintnode);
    }
    choice = '';
    document.title = originalTitle;
    document.removeEventListener('keypress', interpretKeyStroke, true);

    delete Hints;
  }
  function interpretKeyStroke(e) {
    e.preventDefault();
    var key = keycodemapping[(typeof event != 'undefined') ? window.event.keyCode: e.keyCode];
    if (key == 'Enter') {
      if (choice != '') {
        choices.push(choice);
      }
      for (var i = 0; i < choices.length; i++) {
        if (Hints[retrieveNumber(choices[i])] != undefined && Hints[retrieveNumber(choices[i])].type == 'link') {
          var windowname = (choices.length == 1 && !e.ctrlKey) ? '_top': '_blank';
          for (var i = 0; i < choices.length; i++) {
            if (Hints[retrieveNumber(choices[i])] != undefined) {
              window.open(Hints[retrieveNumber(choices[i])].element.href, windowname);
            }
          }
        }
      }
      removeHints();
    } else if (key == ';') {
      if (choice != '') {
        choices.push(choice);
      }
      if (choices.length) {
        var firsthint = Hints[retrieveNumber(choices[0])];
        if (firsthint != undefined) {
          if (firsthint.type == 'text') {
            firsthint.element.focus();
          } else {
            var evt = document.createEvent('HTMLEvents');
            evt.initEvent('click', true, true);
            firsthint.element.dispatchEvent(evt);
          }
        }
      }
      removeHints();
    } else if (key == '\x20') {
      if (choice != '') {
        choices.push(choice);
        Hints[retrieveNumber(choice)].hintnode.style.backgroundColor = bghighlight;
        choice = '';
        document.title += '\x20';
      }
    } else if (key == 'Bkspc') {
      if (choice != '') {
        choice = '';
      } else {
        if (choices.length) {
          var lastchoice = retrieveNumber(choices.pop());
          Hints[lastchoice].hintnode.style.backgroundColor = Hints[lastchoice].bg;
        }
      }
      document.title = originalTitle + '\x20-\x20' + choices.join('\x20');
    } else if (key == undefined) {
      removeHints();
    } else {
      choice += key;
      if (('' + choice).length >= ('' + Hints[Hints.length - 1].hinttext).length || ('' + choice).length >= ('' + Hints[Hints.length - 1].hinttext).length - 1 && retrieveNumber(choice) > retrieveNumber(Hints[Hints.length - 1].hinttext.substr(0, ('' + choice).length))) {
        choices.push(choice);
        Hints[retrieveNumber(choice)].hintnode.style.backgroundColor = bghighlight;
        choice = '';
        document.title += '\x20';
      }
      var choicestring = (choices.length) ? choices.join('\x20') + '\x20' + choice: choice;
      document.title = originalTitle + '\x20-\x20' + choicestring;
    }
  }
  drawHints();
})();

カスタマイズしたい人は

  • ヒントの文字のサイズを絶対指定している。小さすぎるという人は hint.style.fontSize = '9pt'; というところを hint.style.fontSize = 'inherit'; にするといいかも。
  • ヒント要素に opacity を付けているので、黄色と水色が重なって緑に見えるところがある。hint.style.opacity = '.7'; という行を削除すればいいかも。
  • ヒント要素がページ内の要素より下に来てしまう場合は、hint.style.zIndex = '10000'; の値を増やせばいいと思う。
  • oAutoPagerize の有効なページでは左上に SITEINFO アップデート用のリンクが常にある。気になる人は、oAutoPagerize.js のソース中で以下の2行をコメントアウトすればいいと思う。
helpDiv.appendChild(versionDiv);
helpBack.appendChild(helpDiv);

とりあえず今後は

  • 速くする。
  • 自分の使うページだけでもバグを消したい。
  • 関数の階層化などして読みやすくしたい。
    • Blazeix さんの物と意味が変わった変数も名前はそのまま、とかいうものがあるので、それは直す。
  • ゆくゆくは Hit-a-Hint Generator というページを作って、自分好みの設定で Hit-a-Hint が作れるようになればいいかなと。