【JavaScript】見出しまでスクロールできる目次の作り方

2018年11月28日
JavaScript

ブログに目次を設置したかったので作成した。

サンプル

前提

下記の関数やオブジェクトを定義済みの想定とする。

手順

目次を作成するオブジェクトを定義する

目次を作成するため、下記の2つのオブジェクトを定義する。

目次を作成するオブジェクト

function IndexCreator() {
    function _create() {
        const INDEX = document.getElementById( 'index' );
        if ( INDEX === null ) return;

        const CONTENT = document.querySelector( '.content' );
		if ( CONTENT === null ) return;
		
        const HEADING_SELECTORS = '.content > h2, .content > h3, .content > h4';
        const HEADINGS          = CONTENT.querySelectorAll( HEADING_SELECTORS );
        if ( HEADINGS.length === 0 ) return;
      
        _createTitle( INDEX );

        const DIV   = 'div'.createElementAndAddClass( 'items' );
        const ITEMS = _createItems( HEADINGS, DIV );
        INDEX.appendChild( DIV );
        
        const REGISTERER = new IndexEventRegisterer;
        REGISTERER.register( ITEMS, HEADINGS );
    }
  
    function _createTitle( index ) {
        index.insertAdjacentHTML( 'beforeend', '<h3 class="title">目次</h3>' );
    }

    function _createItems( headings, beforeElement ) {
        const getNestCount = function( heading ) {
            let     count       = 1;
            const   NODE_NAME   = heading.nodeName.toLowerCase();

            switch( NODE_NAME ) {
                case 'h3': count = 2; break;
                case 'h4': count = 3; break;
            }

            return count;
        };

        const addNest = function() {
            const OL        = 'ol'.createElement();
            beforeElement   = beforeElement.appendChild( OL );
        };

        const removeNest = function() {
            const OL        = beforeElement.parentNode;
            beforeElement   = OL.parentNode;
        };

        let currentNestCount = 0;

        const adjustNest = function( heading ) {
            const NEST_COUNT = getNestCount( heading );
            if ( currentNestCount === NEST_COUNT ) return false;

            const IS_NEST = currentNestCount < NEST_COUNT;
            
            if ( IS_NEST ) {
                addNest();
            }
            else {
                const REMOVING_COUNT = currentNestCount - NEST_COUNT;
                
                for ( let i = 0; i < REMOVING_COUNT; i++ ) {
                    removeNest();
                }
            }
            
            currentNestCount = NEST_COUNT;

            return IS_NEST;
        };

        let items = [];

        const createItem = function( heading, index ) {
            const IS_NESTED = adjustNest( heading );

            const OL        = IS_NESTED ? beforeElement : beforeElement.parentNode;
            const LI        = 'li'  .createElement();
            const ITEM      = 'div' .createElementAndAddClass( 'item'   );
            const LINE      = 'div' .createElementAndAddClass( 'line'   );
            const NUMBER    = 'div' .createElementAndAddClass( 'number' );
            const TEXT      = 'div' .createElementAndAddClass( 'text'   );
            
            beforeElement = OL.appendChild( LI );
            LI  .appendChild( ITEM      );
            ITEM.appendChild( LINE      );
            LINE.appendChild( NUMBER    );
            LINE.appendChild( TEXT      );

            ITEM.style.paddingLeft  = ( currentNestCount * 10 ) + 'px';
            TEXT.textContent        = heading.textContent;
            
            items.push( ITEM );
        };

        const COUNT = headings.length;
        for ( let i = 0; i < COUNT; i++ ) createItem( headings[i], i );
        
        return items;
    }

    return {
        create: _create,
    }
};

目次のイベントを登録するオブジェクト

function IndexEventRegisterer() {
    const _SCROLLER = new Scroller( document.documentElement, 70, 15 );
  
    function _register( items, headings ) {
        const COUNT = headings.length;

        for ( let i = 0; i < COUNT; i++ ) {
            const ITEM      = items[i]; 
            const HEADING   = headings[i];

            _registerHoverEvent ( ITEM, i );
            _registerPushedEvent( ITEM, HEADING );
        }
    }

    function _registerHoverEvent( item, index ) {
        const _CLASS_NAME = 'hover';
        const onStarted   = function() { item.addClass( _CLASS_NAME ); };
        const onEnded     = function() { item.removeClass( _CLASS_NAME ); };
      
        item.registerOnHover( onStarted, onEnded );
    }

    function _registerPushedEvent( item, heading ) {
        const onPushed = function() { _SCROLLER.scrollByElement( heading ); };

        item.registerOnPushed( onPushed );
    }

    return {
        register: _register,
    };
};

目次を設置する箇所にdiv要素を置く

ページ内の目次を設置する箇所に<div id="index"></div>を置く。

DOMContentLoadedイベントでオブジェクトを生成し、関数を呼ぶ

IndexCreatorオブジェクトを生成し、create関数を呼ぶ処理をdocumentオブジェクトのDOMContentLoadedイベントに登録する。

document.addEventListener( 'DOMContentLoaded', function() {
    const INDEX_CREATOR = new IndexCreator;
    INDEX_CREATOR.create();
} );

ページ内のコンテンツをdivタグで囲む

ページ内のコンテンツを<div class="content"></div>で囲む。

コンテンツ内に見出しを配置する

コンテンツ内にh2~h4のタグを使い、見出しを配置する。

配置例

<div class="content">
  	<h2>見出し1</h2>
  	<p>文章</p>

  	<h3>見出し1.1</h3>
  	<p>文章</p>

  	<h2>見出し2</h2>
  	<p>文章</p>
</div>

これでページを表示した時に目次を設置する箇所に下記のようなHTMLが出力される。

出力例

<div id="index">
	<h3 class="title">目次</h3>
	<div class="items">
		<ol>
			<li>
				<div class="item" style="padding-left: 10px;">
					<div class="line">
						<div class="number"></div><div class="text">見出し1</div>
					</div>
				</div>
				<ol>
					<li>
						<div class="item" style="padding-left: 20px;">
							<div class="line">
								<div class="number"></div><div class="text">見出し1.1</div>
							</div>
						</div>
					</li>
				</ol>
			</li>
			<li>
				<div class="item" style="padding-left: 10px;">
					<div class="line">
						<div class="number"></div><div class="text">見出し2</div>
					</div>
				</div>
			</li>
		</ol>
	</div>
</div>

CSSで見た目を整える

後は出力されたHTMLの各要素に付いているクラスを用いて、CSSで見た目を整える。