github.com に表示される Markdown HTML が TOC が無くて見辛いので「すべて見出し単位でたたむ」「クリックで開閉」する Tampermonkey スクリプトをつくった

github.com はいいかげん TOC(目次)機能を実装するべきだと思う。Markdown で書かれた HTML の全体構造がわからなくて辛い。ふと「見出しだけ表示させてクリックした部分だけ開く」的な小細工を入れたら見やすいかも、とひらめいたので、早速試してみた。

デモ

f:id:stakiran:20190305214351g:plain

要件

Tampermonkey とは

Firefox アドオンの一つ(発祥は Google Chrome 拡張だが)で、Greasemonkey の後継となるスクリプト機構。

「このサイトを開いた時に」「この Javascript を実行してね」ってのを登録できる。

DOM とかいじって要らん要素を消したり、煩雑な情報を上手くまとめたり、と Javascript の書き方次第でウェブサイト(の今あなたが見ている表示)をカスタマイズできる。

インストール

1: Tampermonkey をインストールする

詳細は割愛。

2: 以下スクリプトを登録

// ==UserScript==
// @name         Folding per <h> in github.com
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  H要素単位で折りたたむ&クリックで開閉するスクリプト
// @author       You
// @match        https://github.com/*/*
// @require      http://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js
// @grant        none
// ==/UserScript==

/*
■設定
- ROOT_ELEMENT_SELECTOR
- @match

■仕様など
- click イベントを拾っているので, H要素中でクリック絡む操作する時がうざい
- 他サイトで動くかどうかは正直わからん...

*/

function hide_console_log(msg){
  return;
}
//var cc = console.log;
var cc = hide_console_log;

DisplayController = (function(){
    var DisplayController = function(h_elm_by_jq, index_in_body){
      this._elm_by_jq = h_elm_by_jq;
      this._index = index_in_body;
      this._controllees = [];
    }

    var p = DisplayController.prototype;

    p.add = function(elm_by_jq){
      this._controllees.push(elm_by_jq);
    }

    p.hide_all = function(){
      var controllees = this._controllees;
      for(var i=0;i<controllees.length;i++){
        var controllee = controllees[i];
        controllee.hide();
      }
    }

    p.show_all = function(){
      var controllees = this._controllees;
      for(var i=0;i<controllees.length;i++){
        var controllee = controllees[i];
        controllee.show();
      }
    }

    p.define_events = function(){
      var controllees = this._controllees;

      this._elm_by_jq.click(function(){
        for(var i=0;i<controllees.length;i++){
          var controllee = controllees[i];
          controllee.toggle();
        }
      });
    }

    return DisplayController;
})();

$(function(){
  var ROOT_ELEMENT_SELECTOR = '.markdown-body';
  //var ROOT_ELEMENT_SELECTOR = 'body';

  // ロジック上, 最後の要素が state2 → state1 にならないので,
  // ダミーの h 要素を入れとく.
  var dummy_element_html_for_logic = '<h6></h6>';
  $(ROOT_ELEMENT_SELECTOR).append(dummy_element_html_for_logic)

  var children = $(ROOT_ELEMENT_SELECTOR).children();

  var STATE_0_NOT_STARTED_YET = 'state0';
  var STATE_1_FOUND_HEADER = 'state1';
  var STATE_2_PARSING_THE_REST_ELEMENT_FOR_HIDDING = 'state2';
  var current_state = STATE_0_NOT_STARTED_YET;

  var controllers = [];
  var current_controller = null;
  var controller = null;

  children.each(function(i, elm_by_dom){
    var elm_by_jq = $(elm_by_dom);
    var tagname = elm_by_dom.tagName.toLowerCase();

    var is_header_element = false;
    if(tagname === 'h1' || tagname === 'h2' || tagname === 'h3' || tagname === 'h4' || tagname === 'h5' || tagname === 'h6'){
      is_header_element = true;
    }

    if(current_state === STATE_0_NOT_STARTED_YET){
      if(is_header_element){
        current_state = STATE_1_FOUND_HEADER;

        cc('state1::ここでヘッダを切り替える >'+tagname);
        controller = new DisplayController(elm_by_jq, i);
        controllers.push(controller);
        current_controller = controller;
      }
      return true;
    }

    if(current_state === STATE_1_FOUND_HEADER){
      current_state = STATE_2_PARSING_THE_REST_ELEMENT_FOR_HIDDING;
      // fall through
    }

    if(current_state === STATE_2_PARSING_THE_REST_ELEMENT_FOR_HIDDING){
      if(is_header_element){
        current_state = STATE_1_FOUND_HEADER;

        cc('state2(found)::新しいヘッダ切替前に, 全部 hide() と開閉用イベント登録');
        current_controller.hide_all();
        current_controller.define_events();

        cc('state1::ここでヘッダを切り替える >'+tagname);
        controller = new DisplayController(elm_by_jq, i);
        controllers.push(controller);
        current_controller = controller;

        return true;
      }
      cc('state2(notfound)::ここでヘッダに対応する「見せる見せないを切り替えるやつ」を積んでいく >'+tagname);
      current_controller.add(elm_by_jq);
      return true;
    }

    throw new Error('ここには来ないはず');
  });
});

雑談

主にコードについての話。

どうやって実装したか

まず HTML 文書構造が以下のようになっていることを想定する。

  • 便宜上 h1, h2, ... h6 要素を hx 要素と書く
  • hx 要素以外を not_hx 要素と書く
<RootElement>
 <hx></hx>
 <not_hx></not_hx>
 <not_hx></not_hx>
 ...
 <hx></hx>
 <not_hx></not_hx>
 <not_hx></not_hx>
 ...

つまり特定の要素を基準として、hx が 1 個 → not_hx が n 個、という順でシーケンシャルに並んでいるような構造を想定。

あとはこのシーケンシャル構造を「ある hx は、n 個の not_hx を持つ」という親子構造だと解釈した上で、hx 単位で「まとめて消す」「まとめて表示する」を行う処理とイベントを仕込んだ。

なお、仕込む際のパースは RootElement の children() を取得して、each() で行った。要素を一個ずつ見ていく&状態変数導入して漏れがないように。

使い心地

しばらく試してみる。

長い Markdown の時だけ使ってみて、見出しだけ読む → 読みたいところを開いてサクっと読む、みたいな拾い読み流し読みがスムーズにできるのではないかなぁと期待している。

改造の余地

この仕組みを使うかどうかの判定を Tampermonkey の @match で制御するのは面倒くさいので、以下のようにすればいいかなぁとか何とか思案中。

  • @match は * とかにしてどこでも動作するように
  • サイト読み込んだだけでは何も動かない
  • 何かアクションを行うと、動く
    • アクション is 何?
    • どっかダブルクリックするとか、ショートカットキー仕込むとか……?

ただ、root element がサイトによって違う(ローカルでテストした HTML だと body だし、GitHub だと .markdown-body だし、他のサイトやブログでは当然違ってくる)ので、これをどうやって吸収すればいいかがわからん。