TOC(Table of Contents)についてちょっとサンプルを作ってみました。

とは言いつつも、自分で何かしらしたことはたいしてないのですが、htmlで単純に書いたサイトにスクロールを監視して見出しを書き出したリストに現在どの位置にいるのかがわかるようにしてあります。

前提条件として、見出しが必須です。

サンプル 1

兎にも角にもまずサンプルを。

Gridを使って横に3カラムになってます。

See the Pen TOC with sidebar by Hidekichi (@Hidekichi) on CodePen.

ちなみにここでは横幅が狭いため3カラムではなくモバイル用の表示になっています。Codepenへのリンクでレスポンシブでどうなるかを確認できます。
見出しと本文を囲んでいる赤線はヘッダをsectionで囲めているかの確認用ですのでcssで一番最後の部分を削除したら表示を消すことができます。
Codepenの表示下部表示倍率を変える数値が出ていますが、見た目はここで変えられますが今の所これで縮小表示した場合にcssのみが動作して内部のJavascriptでのレスポンシブの切り替え動作が動いていないようです。このため、どういう見た目になるかだけは確認できますが実際の動作を確認するためには上記リンク等からCodepenで確認してみて下さい。

またNeocitiesで実際確認できるサンプルを作りましたのでこちらでも確認できます。

html部分は、wikipediaの風魔の小次郎から引用しています。特に意味はありません。なんでもよかったのですがたまたま見てたページから引用しました。
htmlペーストできるエディターで記事本文をコピーして、それを貼り付け、

<article>
    <div class="wrapper">
        <div class="main">
            ここに本文を引用
        </div>
    </div>
</article>

このような構造にしてあります。

CSSは、SCSSで書いありますがCSS欄の右にある「∨」から「View Compiled CSS」を選ぶと通常のCSSになります。肝としては、

body.sideToc .wrapper {
  display: grid;
  grid-template-areas: "left center right";
  grid-template-columns: 1fr min(48ch, 100%) 1fr;
  grid-gap: 1rem;
}
body.sideToc .wrapper > .main {
  grid-area: "center";
}
body.sideToc .wrapper > #toc {
  grid-area: "right";
}

この部分かと思います。この状態だけだと、left部分に何もないのでjQueryでleftエリアに空のdivを追加してます。これにより、3カラムで真ん中が48chのサイズより小さくなると100%となるような3カラムができて、真ん中に本文、右側にTOCが入る感じになります。

つまり、htmlでは単純に見出しがあって段落があるだけのレイアウトが、.wrapperに仕込まれたgridの設定で横並びの3カラムになって、真ん中に.main、左に#tocが流し込まれることになります。

TOCは、768px以上の幅があるとposition: stickyによって右カラムに固定されます。これより小さな幅になると本文上に移動します。

サンプル2

こちらのサンプルは、GridではなくてFlexboxを使っています。

See the Pen TOC with sidebar, flex by Hidekichi (@Hidekichi) on CodePen.

Codepenへのリンクでレスポンシブでどうなるかを確認できます。

こちらの場合は、3カラムではなくメインの本文の後にjQueryでTOCを追加しますので若干cssも簡易な感じになっています。また他に追加する空divもないのでスクリプト側も若干少なくなってますが基本は同じです。

flexを使ったものもNeocitiesで実際確認できるサンプルを作りましたのでこちらでも確認できます。

動作について

jQueryでは主にヘッダ(見出し)を探してsectionで囲むということをしています。その囲まれたsectionをスクロールで監視して、今いるスクロール位置に見えているヘッダと対応するTOCのリストをハイライトするようになっています。

このため、

  • ヘッダをsectionで囲む

  • ヘッダからTOCを作る

  • スクロールを監視する

という構成になっていて、更に、sectionにIDを付けて、そのIDと同じリンクを作る必要があるので、それらが可能な2つのサンプルスクリプトを利用しています。

まずは元になる記事としてSticky Table of Contents with Scrolling Active States | CSS-Tricks - CSS-Tricks このサイトの動作を作ってみようと考えました。
ちなみにこの記事の下にあるコメントではJavascriptを使わずcssのみで実現しているサンプルもありました。

しかし、このオリジナルのサンプルを実現するためには、元のhtmlを変更する必要と、TOCも仕様に合わせて作成しなくてはいけなかったので、見出しから目次を自動で作成 | JavaScriptレシピ集 | CookBook こちらのサイトのタイトル通り、見出しから目次を作成する部分を利用させてもらいました。

こちらのサイトではサイト内で書かれていることで今回の目的を実現できますし、スクリプトも綺麗に整理されていてとてもわかり易いのですが、IntersectionObserver を使いたかったので、独自にそれぞれをかけ合わせて利用させてもらってます。

ざっくりと機能するかどうかだけを見ながら作っているのでもっと綺麗に書く方法やJavascriptのみでやったり、不要な部分をもう少し整理できると思いますが、ひとまずはこんな感じで。

Intersection Observer について

IntersectionObserver とは、特定の要素・領域を監視して、監視対象の要素がその領域に入ったかどうかを検知してくれるAPIです。javaScriptの機能です。つまり、スクロールで画面内に入ったsectionをハイライト表示するには使い勝手の良い機能となります。

詳しくは、交差オブザーバー API - Web API | MDN で確認してみて下さい。

window.addEventListener('DOMContentLoaded', () => {

    const observer = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            const id = entry.target.getAttribute('id');
            if (entry.intersectionRatio > 0) {
                document.querySelector(`nav li a[href="#${id}"]`).parentElement.classList.add('active');
            } else {
                document.querySelector(`nav li a[href="#${id}"]`).parentElement.classList.remove('active');
            }
        });
    });

    // Track all sections that have an `id` applied
    document.querySelectorAll('section[id]').forEach((section) => {
        observer.observe(section);
    });

});

これがIntersection Observerの処理で、

if (entry.intersectionRatio > 0) {

この部分が閾値(しきい値)です。entriesには監視するセレクターが入ります。それらをforEachで回してsectionが画面内に入ると、sectionのIDを参照して、該当のnavのリンクに.activeと言うクラスを付与し、画面から外れると.activeを取り去るという感じです。

上記スクリプトのコメントにも書いてありますが、「idが適用されているすべてのセクションを追跡します」ということから、sectionとnav((サンプルの場合は.toc))に同じIDが入っている必要があります。そのためにCookbookさんのスクリプトが丁度よかったと言うことです。

ちなみに、window.addEventListener('DOMContentLoaded', () => {だと、htmlが読み込まれた段階で実行してしまうので、まだjQuery等でゴニョゴニョできてないため、DOMContentLoaded は load に変更しています。画像なども読み終えてから処理したい場合はloadです。

どうして、Intersection Observerを利用するかというと、例えば、スクロールを監視して画面の高さも調べて、スクロールしてる中で目的の要素が画面内に入ったかどうかということは調べることは可能です。
しかし、それだと常にスクロールするたびにチェック(callback)が入るわけです。もちろん、それらのチェックは何かしらで行われてはいるわけですがIntersection Observerの場合、処理を実行するのは該当する時のみなのでブラウザへの負担が軽く済みます。

これらは以前なら、スクロールの最中の処理をbounceやthresholdで間引いたりしてきたわけですが、同等あるいはより簡素にできるようにIntersection Observerなどが導入されたんだろうと思います。

TOCだけを作るのであればもっと簡単にできるわけですが、こういった機能を使うと今どこで何を見ているのかがわかって便利ですね。このサイトにも組み込もうかと思って作り始めましたが、色々と変更する点が多くて面倒なので今の所まだ実装してませんが、そのうち時間がある時に構造自体を変更して試してみようかと思います。

Bloggerのテーマというか、外部cssが使えないあるいは使うのがめんどい仕様だと色々大変なのでしばらくは先になりそうな予感もあります。ただ、今回作ったスクリプト自体は素のhtmlで見出しと段落があるような書き方のサイトであれば簡単に3カラムや、Flexによるほぼ1カラムのサイトが作れますので、アイデア次第で使える場面は色々とあるのではなかろうかと。