目次

見出しから目次を自動で作成

JavaScriptで見出しから目次を自動で作成するプログラムの作り方をご紹介します。設置も簡単ですよ。


PREPARATION


想定する読者と前提条件

基本的なパソコン操作ができること。HTML及びJavaScriptの基礎程度の知識が必要です。


START


見出し抜き出し機能作成

では、早速見出しから目次を作成するプログラムを作成してみましょう。 やることも単純で、対象となる見出しを探し、見出しのランクに応じて目次の階層構造(ol要素を使用します)を作成するというものになります。

やることを確認しよう

では、プログラムを作成する手順を確認しましょう。

  • 見出しを出力する要素を検索する機能
  • 対象となる見出し要素を検索する機能
  • 検索した見出しのランクに応じで階層分け(ol)する機能
  • 目次を作成する機能
  • 目次を出力する機能

見出しを出力する要素を検索する機能

これは非常にシンプルです。document.querySelectorを使うことで、出力先の要素を検索することができます。 document.querySelectorにはCSSのセレクタを指定できますので、ID名やクラス名で対象要素を1つだけ検索できます。

下記の例では、TOC_INSERT_SELECTORに出力する要素のID名を入れ、document.querySelectorで検索を行います。

const TOC_INSERT_SELECTOR = '#toc';              // [セレクター指定] 目次を挿入する要素 querySelector用
const tocInsertElement    = document.querySelector(TOC_INSERT_SELECTOR);

対象となる見出し要素を検索する機能

これも非常にシンプルです。document.querySelectorAllを使うことで、見出し要素を検索することができます。 document.querySelectorAllにはCSSのセレクタを指定できますので、タグ名やID名やクラス名で対象要素を複数検索できます。 HEADING_SELECTORに検索する見出しのセレクターを入れ、document.querySelectorAllで検索を行います。

以下のように、h1,h2のようにも記述できますし、収集対象のh要素に「tocheading」と言うクラス名を付けた場合は、 .tocheadingとHEADING_SELECTORに指定することもできます。

const HEADING_SELECTOR    = 'h1,h2,h3,h4,h5,h6'; // [セレクター指定] 収集する見出し要素 querySelectorAll用
const headingElements     = document.querySelectorAll(HEADING_SELECTOR);

ランクよって階層分けする機能

これが非常に重要な機能です。見出しの階層構造を定義するためには、現在のランクと直前のランクを比較する必要があります。 Number(el.tagName.substring(1))で、h要素の1〜6の数字を抜き出しています。(つまりh要素以外では動作しません。) ランクは、数字が小さいほど高く、数字が大きいほど低くなります。(h1〜h6でh1が最もランクが高いためです。) それをrank、oldRankに保持し、layer変数で管理を行います。

layerの階層は、そのままh1~h6の階層と一致させています。layer[1]にはh1,layer[6]にはh6が対応します。

findParentElementは、現在のランクから見た上のランクの探索を行います(上のランクを探すためdiffは-1)。 仮に直前のランクより今のランクが低い場合には、findParentElementで見つけた要素の子要素として目次を追加します。 ランクが低い要素が初めて見つかった場合には、ol要素の作成も行います。 直前のランクより今のランクが高い場合には、layerから今よりも低いランクを削除(layer.length = rank + 1で行っています)し、layerに新たな目次(li)を追加します。

const layer = [];
let oldRank = -1;
const findParentElement = (layer, rank, diff) => {
    do {
        rank += diff;
        if (layer[rank]) return layer[rank];
    } while (0 < rank && rank < 7);
    return false;
};
headingElements.forEach( (el) => {
    let rank   = Number(el.tagName.substring(1));
    let parent = findParentElement(layer, rank, -1);
    if (oldRank > rank) layer.length = rank + 1;
    if (!layer[rank]) {
        layer[rank] = document.createElement('ol');
        if (parent.lastChild) parent.lastChild.appendChild(layer[rank]);
    }
    layer[rank].appendChild(createLink(el));
    oldRank = rank;
});

目次を作成する機能

これも非常にシンプルです。目次は全体をol要素で作成するため、li要素、a要素で作成します。 見つけた見出しにuid関数でユニークなID名を割り振り(元のIDがある場合にはそのまま利用)、そのIDを使って目次のa要素にページ内リンク(フラグメント識別子)を設定します。

const createLink = (el) => {
    let li = document.createElement('li');
    let a  = document.createElement('a');
    el.id  = el.id || uid();
    a.href = `#${el.id}`;
    a.innerText = el.innerText;
    a.className = LINK_CLASS_NAME;
    li.appendChild(a);
    return li;
};

目次を出力する機能

目次を出力する要素に対して、ランクの管理を行うlayer変数の最も高いランクを出力するだけです。 findParentElementは、ランク1(h1)から探索を行い最もランクが高い目次を見つけ出します(diffは下のランクを探すため+1)。

const appendToc = (el, toc) => {
    el.appendChild(toc.cloneNode(true));
};
if (layer.length) appendToc(tocInsertElement, findParentElement(layer, 0, 1));

目次作成機能完成版

以上の機能を組み合わせて、見出しを検索し、目次を作成する機能が完成します。

目次作成プログラム

<script>
{
    // 設定
    const TOC_INSERT_SELECTOR = '#toc';              // [セレクター指定] 目次を挿入する要素 querySelector用
    const HEADING_SELECTOR    = 'h1,h2,h3,h4,h5,h6'; // [セレクター指定] 収集する見出し要素 querySelectorAll用
    const LINK_CLASS_NAME     = 'tocLink';           // [クラス名] 目次用aタグに追加するクラス名     .無し
    const ID_NAME             = 'heading';           // [ID名]    目次に追加するID名のプレフィックス #無し
    const tocInsertElement    = document.querySelector(TOC_INSERT_SELECTOR);
    const headingElements     = document.querySelectorAll(HEADING_SELECTOR);
    const layer = [];
    let id = 0;
    const uid   = () =>`${ID_NAME}${id++}`;
    let oldRank = -1;
    try {
        const createLink = (el) => {
            let li = document.createElement('li');
            let a  = document.createElement('a');
            el.id  = el.id || uid();
            a.href = `#${el.id}`;
            a.innerText = el.innerText;
            a.className = LINK_CLASS_NAME;
            li.appendChild(a);
            return li;
        };
        const findParentElement = (layer, rank, diff) => {
            do {
                rank += diff;
                if (layer[rank]) return layer[rank];
            } while (0 < rank && rank < 7);
            return false;
        };
        const appendToc = (el, toc) => {
            el.appendChild(toc.cloneNode(true));
        };
        headingElements.forEach( (el) => {
            let rank   = Number(el.tagName.substring(1));
            let parent = findParentElement(layer, rank, -1);
            if (oldRank > rank) layer.length = rank + 1;
            if (!layer[rank]) {
                layer[rank] = document.createElement('ol');
                if (parent.lastChild) parent.lastChild.appendChild(layer[rank]);
            }
            layer[rank].appendChild(createLink(el));
            oldRank = rank;
        });
        if (layer.length) appendToc(tocInsertElement, findParentElement(layer, 0, 1));
    } catch (e) {
        //error 
    }
}
</script>


TEST


見出しの入力

目次の出力

目次クリック機能の実装

次に目次をクリックした場合に、対応する見出しに自動的に移動する機能を作ってみましょう。 この機能は、目次のhash値から見出し要素を見つけ、見出し位置にスクロールします。 -100の部分は、ナビゲーション要素のサイズによって調整してください。 (移動アニメーション無しです。と言うより移動アニメーションのメリットが利用者に有りません。)

目次クリックプログラム

const links = document.querySelectorAll(`.${LINK_CLASS_NAME}`);
links.forEach((el) => {
    el.addEventListener("click",(e)=>{
        const targetEl = document.querySelector(el.hash);
        scrollTo(0, window.pageYOffset + targetEl.getBoundingClientRect().top - 100);
        e.preventDefault();
        e.stopPropagation();
    });
});

目的の要素までスクロールするより新しい方法としては、Element.scrollIntoViewが利用できます。この場合、scrollToの箇所を以下の通り書き換える 事が可能です。(ただし、オプションに関しては対応していないブラウザも多いため利用は限定的です)

scrollIntoView DEMO


scrollTo(0, window.pageYOffset + targetEl.getBoundingClientRect().top - 100);
↓
targetEl.scrollIntoView({behavior:"auto", block:"center", inline:"center"});//要素が画面中央に来るように移動。

目次ハイライト機能

次にスクロール中に目次に対応する見出しが表示された場合、目次をハイライトする機能を追加しましょう。 スクロール位置と見出し位置を比較することで、ハイライトする目次の検索を行います。 この機能は、ハイライトする目次のa要素にactiveと言うclass名を付与します。下記のプログラムでは同時に2つの目次に対応する見出しが両方画面内に表示された場合には、後ろの見出しが優先され、対応する目次がハイライトします。(他のハイライト方法(最初優先や複数等)は、最後にご紹介しています。) また、画面の範囲内に見出しがない場合には、直前の見出しをハイライトしています。

目次ハイライトプログラム

const tocHighlight = (e) => {
    const sy = window.pageYOffset;
    const ey = sy + document.documentElement.clientHeight;
    let tocHighlightEl = null;
    links.forEach( (el) => {
        const targetEl = document.querySelector(el.hash);
        const y = sy + targetEl.getBoundingClientRect().top ;
        el.classList.remove("active") ;
        if(sy < y &&  y < ey)tocHighlightEl = el;
        if(sy > y) tocHighlightEl = el;
    });
    if(tocHighlightEl)tocHighlightEl.classList.add("active");
};
tocHighlight();
window.addEventListener("scroll", tocHighlight);

プログラムの完成

最終的に先程の3つの機能を組み合わせたものが完成版です。 コード量も70行程度と非常にシンプルな実装になりました。

最終プログラム

<script>
/**
    Table of Contents

    Copyright (c) 2020 Kamosan https://cookbook.xrea.jp/

    This software is released under the MIT License.
    http://opensource.org/licenses/mit-license.php
*/
{
    const TOC_INSERT_SELECTOR = '#toc';              // [セレクター指定] 目次を挿入する要素 querySelector用
    const HEADING_SELECTOR    = 'h1,h2,h3,h4,h5,h6'; // [セレクター指定] 収集する見出し要素 querySelectorAll用
    const LINK_CLASS_NAME     = 'tocLink';           // [クラス名] 目次用aタグに追加するクラス名     .無し
    const ID_NAME             = 'heading';           // [ID名]    目次に追加するID名のプレフィックス #無し
    const tocInsertElement    = document.querySelector(TOC_INSERT_SELECTOR);
    const headingElements     = document.querySelectorAll(HEADING_SELECTOR);
    const layer = [];
    let id = 0;
    const uid   = () =>`${ID_NAME}${id++}`;
    let links = null;
    let oldRank = -1;
    try {
        const createLink = (el) => {
            let li = document.createElement('li');
            let a  = document.createElement('a');
            el.id  = el.id || uid();
            a.href = `#${el.id}`;
            a.innerText = el.innerText;
            a.className = LINK_CLASS_NAME;
            li.appendChild(a);
            return li;
        };
        const findParentElement = (layer, rank, diff) => {
            do {
                rank += diff;
                if (layer[rank]) return layer[rank];
            } while (0 < rank && rank < 7);
            return false;
        };
        const appendToc = (els, toc) => {
            el.appendChild(toc.cloneNode(true));
        };
        const tocHighlight = (e) => {
            const sy = window.pageYOffset;
            const ey = sy + document.documentElement.clientHeight;
            let tocHighlightEl = null;
            links.forEach( (el) => {
                const targetEl = document.querySelector(el.hash);
                const y = sy + targetEl.getBoundingClientRect().top ;
                el.classList.remove("active") ;
                if(sy < y &&  y < ey)tocHighlightEl = el;
                if(sy > y) tocHighlightEl = el;
            });
            if(tocHighlightEl)tocHighlightEl.classList.add("active");
        };
        headingElements.forEach( (el) => {
            let rank   = Number(el.tagName.substring(1));
            let parent = findParentElement(layer, rank, -1);
            if (oldRank > rank) layer.length = rank + 1;
            if (!layer[rank]) {
                layer[rank] = document.createElement('ol');
                if (parent.lastChild) parent.lastChild.appendChild(layer[rank]);
            }
            layer[rank].appendChild(createLink(el));
            oldRank = rank;
        });
        if (layer.length) appendToc(tocInsertElement, findParentElement(layer, 0, 1));
        links = document.querySelectorAll(`.${LINK_CLASS_NAME}`);
        links.forEach((el) => {
            el.addEventListener("click",(e)=>{
                const targetEl = document.querySelector(el.hash);
                scrollTo(0, window.pageYOffset + targetEl.getBoundingClientRect().top - 100);
                e.preventDefault();
                e.stopPropagation();
            });
        });
        tocHighlight();
        window.addEventListener("scroll", tocHighlight);
    } catch (e) {
        //error 
    }
}
</script>

使い方

目次位置の指定、収集する見出しの指定、後はほんの少しのCSSを追加すれば完成です。

  • TOC_INSERT_SELECTORに目次を出力する要素をセレクター(クラス名またはID)で指定します。
  • HEADING_SELECTORに収集対象のhタグをセレクター(タグ名、クラス名またはID)で指定します。
  • 最終プログラムをbody要素の最後に貼り付ければ完成です。ファイルとして保存し、読み込んでも可です。
  • 目次は、olの入れ子構造で出力されます。
  • 最上位ランクの見出しに紐づいて出力されます。h3→h1等の場合、最初のh3は出力されません。h1→h3→h1はOKです。
  • 目次をハイライトする場合には、activeと言うクラス名に対してcssを適用してください。

目次動作チェック

ol,li要素にCSSの countersを用いることで簡単に連番を割り振ることができます。


ol {
  counter-reset: section;
  list-style-type: none;
}
li::before {
  counter-increment: section;
  content: counters(section, ".") " ";
}


QUESTION


  • avatar
    目次ハイライトの方法を変えたいんだが?

    ハイライトが最後優先だと違和感がある時があるかな?最初の要素を優先したり、画面内の見出しに対応した目次をすべてハイライトしたりすることもできるのかい?

  • 簡単よ。

    tocHighlight関数に少しだけ手を加えれば簡単に対応することができるわ。

    avatar

最初の要素優先ハイライト

const tocHighlight = (e) => {
    const sy = window.pageYOffset;
    const ey = sy + document.documentElement.clientHeight;
    let tocHighlightEl = [null,null];
    links.forEach( (el) => {
        const targetEl = document.querySelector(el.hash);
        const y = sy + targetEl.getBoundingClientRect().top ;
        el.classList.remove("active") ;
        if(sy < y &&  y < ey && !tocHighlightEl[1]){tocHighlightEl[1] = el;tocHighlightEl[0] = null;}
        if(sy > y) tocHighlightEl[0] = el;
    });
    if(tocHighlightEl.length)tocHighlightEl.forEach( (el) => {el && el.classList.add("active");});
};

最初優先目次動作チェック

表示範囲内全てハイライト

const tocHighlight = (e) => {
    const sy = window.pageYOffset;
    const ey = sy + document.documentElement.clientHeight;
    let tocHighlightEl = [null];
    links.forEach( (el) => {
        const targetEl = document.querySelector(el.hash);
        const y = sy + targetEl.getBoundingClientRect().top ;
        el.classList.remove("active") ;
        if(sy < y &&  y < ey){tocHighlightEl.push(el); tocHighlightEl[0] = null;}
        if(sy > y) tocHighlightEl[0] = el;
    });
    if(tocHighlightEl.length)tocHighlightEl.forEach( (el) => {el && el.classList.add("active");});
};

範囲内全てハイライト目次動作チェック


COMPLETE


  • avatar
    お疲れ様でした!

    今回は基本機能しか実装していません。先ほど説明したとおりハイライトの動作変更も簡単にできますので皆さんの力で色々な機能や見た目を追加してみてください。 このページの目次も上記コード(最初の要素優先ハイライト)を利用して動作しています。ご参考までに。

    目次が長い場合、目次ハイライト場所に合わせてスクロールするには、el.scrollIntoViewを使用し対応可能です。試してみてください。


コメント