【jQuery】記事の見出しを自動取得して目次を作ってみよう!

このブログをマテリアルデザインっぽくリニューアルしたときに実装した機能をご紹介するコーナーがやって来ました。

第一弾はレスポンシブデザインにおけるテーブルの可変パターンでしたが、今回ご紹介するのは、jQueryによる目次の自動生成です。

WordPressで目次生成といえば「Table of Contents Plus」という有名なプラグインがありますが、今回は今見ている項目の見出しをハイライトさせたかったので今回は自前で実装してみました。

jQueryで実装すればサーバーに負担も掛けないし、はてなブログなどでも利用できるので汎用性が高いと思います。

スポンサードリンク

デモ・ダウンロード

とりあえずサンプルを見て下さい。当ブログのデザインを簡素化したもので、横幅768pxより大きければサイドバー部分に目次が追従してきます。タブレットやスマートフォンではあえて非表示にしています。

スムーズスクロールのエフェクトのためにjQuery UIを読み込んでいますが、slowとかfastとかの通常の動きであれば無くても大丈夫です。

仕様

  • メインカラムの見出し(h2, h3, h4)を自動で取得して目次を生成
  • スクロール位置に応じて目次の色が変わる(現在地表示)
  • 見出しの入れ子(h3, h4)がある場合、アコーディオンにしておく
  • アコーディオンを一括で開閉できるボタンを見出し横に設置
  • 見出しの入れ子が現在地の場合、親要素の色も変わる
  • サイドバーに固定配置し、タブレットやスマホでは非表示

html


<!DOCTYPE html>
<!--[if lt IE 7 ]> <html  lang="ja" id="ie6" class="ie"> <![endif]--> 
<!--[if IE 7 ]> <html lang="ja" id="ie7" class="ie"> <![endif]--> 
<!--[if IE 8 ]> <html lang="ja" id="ie8" class="ie"> <![endif]--> 
<!--[if IE 9 ]> <html lang="ja" id="ie9" class="ie"> <![endif]--> 
<!--[if (gt IE 9)|!(IE)]><!--> <html> <!--<![endif]-->
<head>
<title>jQueryで目次を作ってみる</title>

<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">

<link rel="stylesheet" type="text/css" href="http://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="css/reset.css">
<link rel="stylesheet" type="text/css" href="css/style.css">

<!--[if lt IE 9]>
<script src="//cdn.jsdelivr.net/html5shiv/3.7.2/html5shiv.min.js"></script>
<![endif]-->
 
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/jquery-ui.min.js"></script>
<script src="js/script.js"></script>
</head>

<body>

<header>
<h1>サイトタイトルサイトタイトルサイトタイトル</h1>
</header>
<div id="contents" class="clearfix">
  <div id="main">
  <article>
  <h2>タイトル1タイトル1タイトル1タイトル1</h2>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h3>タイトル1-2タイトル1-2タイトル1-2タイトル1-2</h3>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h4>タイトル1-2-1タイトル1-2-1タイトル1-2-1タイトル1-2-1</h4>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h4>タイトル1-2-2タイトル1-2-2タイトル1-2-2タイトル1-2-2</h4>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h4>タイトル1-2-3タイトル1-2-3タイトル1-2-3タイトル1-2-3</h4>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
    <h3>タイトル1-3タイトル1-3タイトル1-3タイトル1-3</h3>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h2>タイトル2タイトル2タイトル2タイトル2</h2>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h3>タイトル2-1タイトル2-1タイトル2-1タイトル2-1</h3>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  <h3>タイトル2-2タイトル2-2タイトル2-2タイトル2-2</h3>
  <p>テキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>
  </article>
  <!-- #main --></div>
  
  <div id="side">
  <div class="mokuji">
  <!-- .mokuji --></div>
  <!-- #side --></div>
  
  <!-- /.contents --></div>

<footer>
  <p>フッターフッターフッター</p>
</footer>
</body>
</html>

CSS


@charset "utf-8";

body {
	font-family:"游ゴシック",YuGothic,"ヒラギノ角ゴ Pro W3","Hiragino Kaku Gothic Pro",Verdana,"メイリオ",Meiryo,Osaka,"MS Pゴシック","MS PGothic",sans-serif;
}

header {
	padding: 1em 1em .8em;
	color: #6c501b;
	font-size:2em;
	background: #fdd835;
	text-align:center;
}

#contents {
	position:relative;
	width:92%;
	max-width:960px;
	margin:30px auto 450px;
}

#main {
	width:100%;
	padding-right:320px;
	box-sizing:border-box;
}
article h2 {
	margin-bottom:1em;
	padding: 1em 1.25em .9em;
	font-size: 120%;
	border-top: 4px solid #ffc045;
	background: #f2f2f2;
	text-shadow: 1px 1px 0 rgba(255,255,255,0.5);
}
article h3 {
	margin: 2em 0;
	padding: .5em 1.25em .4em;
	border-left: 4px solid #ffc045;
}
article h4 {
	display: inline-block;
	margin-bottom: 1em;
	padding: .5em 1em;
	color: #6c501b;
	font-weight:bold;
	background: #fdd835;
	border-radius: 3px;
}
article p {
	margin-bottom:2em;
	line-height:1.5;
}

/* サイドバー */
#side {
	position:absolute;
	top:0;
	right:0;
	width:300px;
}

/* 目次 */
.mokuji {
	background:#fff;
	box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.1);
}
.mokuji.fixed-side {
	position:fixed;
	top:30px;
	left:auto;
	width:300px;
	box-shadow: 0px 5px 30px rgba(0, 0, 0, 0.2);
}

.mokuji h4 {
	position:relative;
	padding:1em 1em 0.8em;
	color:#6C501B;
	font-size:96%;
	font-weight:bold;
	background:#FDD835;
	text-shadow:1px 1px 0 rgba(255,255,255,0.5);
}
.mokuji h4 .kao {
	display:inline-block;
	*display:inline;
	*zoom:1;
	margin-left:0.5em;
	font-weight:normal;
}
.mokuji h4 .closeBtn {
	cursor:pointer;
	position:absolute;
	top:11px;
	right:12px;
	font-size:1.5em;
	-webkit-transition:0.2s;
	-moz-transition:0.2s;
	transition:0.2s;
	-webkit-transform: rotate(45deg);
	-moz-transform: rotate(45deg);
	-ms-transform: rotate(45deg);
	-o-transform: rotate(45deg);
	transform: rotate(45deg);
	-webkit-transform-origin:center;
	-moz-transform-origin:center;
	-ms-transform-origin:center;
	-o-transform-origin:center;
	transform-origin:center;
}
.mokuji h4 .closeBtn.active {
	-webkit-transform: rotate(0deg);
	-moz-transform: rotate(0deg);
	-ms-transform: rotate(0deg);
	-o-transform: rotate(0deg);
	transform: rotate(0deg);
}
.mokuji .mokujiInner {
	border:1px solid #ddd;
	border-top:none;
}

/* インデント */
.mokuji li {
	overflow:hidden;
	position:relative;
	cursor:pointer;
	width:100%;
	height:100%;
	/*text-indent:-1em;*/
	list-style:none;
	-webkit-transition:0.2s;
	-moz-transition:0.2s;
	transition:0.2s;
	-webkit-box-sizing:border-box;
	-moz-box-sizing:border-box;
	box-sizing:border-box;
}
.mokuji ul.mokujiShare li {
	list-style:none;
	text-align:center;
	background:#f2f2f2;
}
.mokuji ol ol {	display:none; }
.mokuji ol ol li { padding-left:1em; }
.mokuji ol ol ol li { padding-left:2em; }

.mokuji li a {
	display:block;
	padding:1em;
	color:#333;
	font-size:75%;
	line-height:1.5;
	text-decoration:none;
}
.mokuji > ol li.accordion .inner a {
	padding-left:0;
}
.mokuji li:hover {
	background:#f2f2f2;
}
.mokuji li.current {
	background:#FFECB3;
}
.mokuji li.active {
	background:#f00;
}
.mokuji li.current a {
	color:#6C501B;
	font-weight:bold;
}

/* アコーディオン */
.mokuji li.accordion {
	padding:0;
}
.mokuji li.accordion .inner {
	display:table;
	width:100%;
	table-layout:fixed;
}
.mokuji li.accordion .inner a {
	display:table-cell;
	width:100%;
	text-indent:0;
	padding: 1em;
	vertical-align:middle;
}
.mokuji li.accordion .inner .accBtn {
	display:table-cell;
	width: 30px;
	margin-top:12px;
	font-size: 1.5em;
	text-indent:0;
	color:#fff;
	background:#555;
	text-align:center;
	vertical-align:middle;
}

/* フッター */
footer {
	position:relative;
	padding: 2em 0;
	color:#fff;
	background: #333;
	z-index:2;
}
footer p {
	width:92%;
	max-width:960px;
	margin:0 auto;
}

/* タブレット・スマートフォン */
@media screen and (max-width:768px){
	#main {
	padding-right:0;
	}
	#side {
	width:auto;
	}
	.mokuji {
	display:none;
	}
}

ステップ1:見出しを元に目次を自動生成する

まずはシンプルに見出しを元に目次を作ってみましょう。一旦カレント表示やアコーディオンのことは忘れておいて、見出しが<ol><li>の入れ子になっていればOK。

See the Pen jQuery table of contents #01 by tokumewi (@tokumewi) on CodePen.


$(function() {	
	/* -------------------------------------------------------
		記事の見出しから目次作成
	--------------------------------------------------------*/
	function makeMokuji() {
		
		var idcount = 1;  // IDのカウント
		var mokuji = '';  // 目次のHTML格納場所
		var currentlevel = 0  // 見出しのレベル初期値
		
		// 見出しを回してリストに格納
		$('article h2, article h3, article h4').each(function(i){
			
			// 見出しごとにIDを保存
			this.id = 'chapter-' + idcount;
			idcount ++;
			
			// 見出しのレベル設定
			var level = 0;
			if(this.nodeName.toLowerCase() == 'h2') {
				level = 1;
			} else if(this.nodeName.toLowerCase() == 'h3') {
				level = 2;
			} else if(this.nodeName.toLowerCase() == 'h4') {
				level = 3;
			}
      
			// 見出しのレベルが現在のレベルよりも数値が大きければ
			// <ol>を追加して入れ子にする
			while(currentlevel < level) {
				mokuji += '<ol class="chapter">';
				currentlevel ++;
			}
      
			// そうでなければ</ol>で閉じて入れ子を終了する
			while(currentlevel > level) {
				mokuji += '</ol>';
				currentlevel --;
			}
			
			// リストを生成
			mokuji += '<li><a href="#' + this.id + '">' + $(this).html() + '</a></li>\n';
		});
		
		// 現在のレベルが0より上ならリストを閉じる
		while(currentlevel > 0) {
			mokuji += '</ol>';
			currentlevel --;
		}	
				
		// HTML出力
		strMokuji = '<h4>目次で流し読みする <span class="kao">・*・:≡( ε:)</span></h4>\
					 <div class="mokujiInner">'
						+ mokuji +
					 '<!-- /.mokujiInner --></div>';
					
		$('.mokuji').html(strMokuji);
		
		/* -------------------------------------------------------
			リストクリックでスムーズスクロール
		--------------------------------------------------------*/
		$('.mokuji li').click(function(){
			var speed = 800;
			var href = $(this).find('a').attr('href');
			var target = $(href == '#' || href == '' ? 'html' : href);
			var position = target.offset().top;
			$('html, body').stop().animate({scrollTop:position}, speed, 'easeInOutCirc');
			return false;
		});
		
	}
	makeMokuji();
});

makeMokuji()という目次を作るための関数を作ります。詳しい理由は後述しますが、こうすることで実行するタイミングを任意で設定することができます。

この目次リストの作成部分は、以下の記事をほとんどそのまま使っています。

一番最後のリストクリックでスムーズスクロールは特に難しいことをやっていないので割愛します。コピペでそのまま使えるよ!

変数を定義する

はじめに必要な変数を最初で宣言しておきます。idcountは見出しごとに割り振るID、mokujiは生成するリストの入れ物、currentlevelが見出しのレベルです。このレベルを使って入れ子を作っていきます。


var idcount = 1;  // IDのカウント
var mokuji = '';  // 目次のHTML格納場所
var currentlevel = 0  // 見出しのレベル初期値

見出しを探して何かをする「.each()」


// 見出しを回してリストに格納
$('article h2, article h3, article h4').each(function(i){
	~ここに処理を記入~
});

<article>の中にある見出しタグを探して順番に処理をしていきます。

もし見出しの中でも除外したい要素があれば、.each()の前に.not(‘.hoge’)という風に書くとスキップしてくれます。


// 見出しを回してリストに格納
$('article h2, article h3, article h4').not('.hoge').each(function(i){
	~ここに処理を記入~
});

見出しごとにIDを設定


// 見出しごとにIDを保存
this.id = 'chapter-' + idcount;
idcount ++;

続いてアンカーリンクに使うIDを見出しごとに設定します。頭に「chapter-」と付けて、変数のidcountを組み合わせます。IDを設定した後にカウントを1ずつ増やすことで、chapter-1、chapter-2、chapter-3…という感じで割り当てられます。

まずは入れ子なしのリストを出力してみる

リストの入れ子は一旦置いといて、全部の見出しを上から順番に<li>タグで囲んだリストをHTMLに出力してみます。


mokuji += '<ol class="chapter">';

// 見出しを回してリストに格納
$('article h2, article h3, article h4').each(function(i){

	// リストを生成
	mokuji += '<li><a href="#' + this.id + '">' + $(this).html() + '</a></li>\n';

});

mokuji += '</ol>';

// HTML出力
strMokuji = '<h4>目次で流し読みする <span class="kao">・*・:≡( ε:)</span></h4>\
<div class="mokujiInner">'
+ mokuji +
'<!-- /.mokujiInner --></div>';

$('.mokuji').html(strMokuji);

事前に用意しておいたmokujiという変数に、リストタグの元となるパーツを格納します。this.idには先ほど見出しに設定したIDと同じ変数が入り、$(this).html()はその見出しのテキストが入ります。

この集めたmokujiを目次全体のタグstrMokujiという変数に合体して、最後に用意しておいた.mokujiの中に挿入します。

見出しのレベルに応じて入れ子にしていく

このままでは見出しがない記事だと<li>が生成されずに親要素のみが残ってしまいます。そこで見出しごとにレベルを設定して、現在のレベルよりも数値が大きくなれば<ol class="chapter">を追加してリストを入れ子にしていきます。

より具体的な例にするために見出しのサンプルを作りました。


<h1>記事タイトル記事タイトル記事タイトル</h1>
	<h2>タイトル1</h2>
		<h3>タイトル1-1</h3>
			<h4>タイトル1-1-1</h4>
			<h4>タイトル1-1-2</h4>
		<h3>タイトル1-2</h3>
	<h2>タイトル2</h2>

ココで言う<h2>は記事タイトルの<h1>に対する見出しであり、<h3>は<h2>に対する見出し、<h4>は<h3>に対する見出しというようにどんどん階層が出来ています。

こうすることで内容ごとにブロックが出来て話の全体が掴みやすくなりますね。入れ子構造を維持したまま目次に反映するには、先ほどのjsに以下のような処理を追記します。


// 見出しを回してリストに格納
$('article h2, article h3, article h4').each(function(i){
	
	// 見出しごとにIDを保存
	this.id = 'chapter-' + idcount;
	idcount ++;
	
	// 見出しのレベル設定
	var level = 0;
	if(this.nodeName.toLowerCase() == 'h2') {
		level = 1;
	} else if(this.nodeName.toLowerCase() == 'h3') {
		level = 2;
	} else if(this.nodeName.toLowerCase() == 'h4') {
		level = 3;
	}

	// 見出しのレベルが現在のレベルよりも数値が大きければ
	// <ol>を追加して入れ子にする
	while(currentlevel < level) {
		mokuji += '<ol class="chapter">';
		currentlevel ++;
	}

	// そうでなければ</ol>で閉じて入れ子を終了する
	while(currentlevel > level) {
		mokuji += '</ol>';
		currentlevel --;
	}
	
	// リストを生成
	mokuji += '<li><a href="#' + this.id + '">' + $(this).html() + '</a></li>\n';
});

// 現在のレベルが0より上ならリストを閉じる
while(currentlevel > 0) {
	mokuji += '</ol>';
	currentlevel --;
}

// HTML出力
strMokuji = '<h4>目次で流し読みする <span class="kao">・*・:≡( ε:)</span></h4>\
	<div class="mokujiInner">'
		+ mokuji +
	'<!-- /.mokujiInner --></div>';

$('.mokuji').html(strMokuji);

ちょっとソースコードが長くなってしまいましたが、順を追って見ていきましょう。

見出しのレベル設定


// 見出しのレベル設定
var level = 0;
if(this.nodeName.toLowerCase() == 'h2') {
	level = 1;
} else if(this.nodeName.toLowerCase() == 'h3') {
	level = 2;
} else if(this.nodeName.toLowerCase() == 'h4') {
	level = 3;
}

まず8行目にある「見出しのレベル設定」ではlevelという変数の初期値に0を設定しておき、見出しの階層が深くなるにつれてレベルを高く設定しています。このレベルを元に入れ子にするための条件分岐をしていきます。

見出しのレベルが現在のレベルよりも数値が大きければ入れ子にする


// 見出しのレベルが現在のレベルよりも数値が大きければ
// <ol>を追加して入れ子にする
while(currentlevel < level) {
	mokuji += '<ol class="chapter">';
	currentlevel ++;
}

18行目では現在地のレベルcurrentlevelと、先ほど設定した見出しのレベル設定levelを比較して、後者が大きければ入れ子にします。currentlevelの初期値は「0」なので、(0 < 1)1番最初の<ol class="chapter">を展開します。

展開した後はcurrentlevelに1を足して現在地は「1(階層下)」になりました。引き続きmokujiにリストのHTMLを代入したあと、次の見出しを参照していきます。これを繰り返して目次の入れ子を生成します。

次の見出しは「<h3>タイトル1-1</h3>」なので見出しのレベルは「2」になり、currentlevelの「1」と比較して大きい(1 < 2)ので更に入れ子にします。

見出しのレベルが現在のレベルよりも数値が小さければ閉じタグを入れる


// そうでなければ</ol>で閉じて入れ子を終了する
while(currentlevel > level) {
	mokuji += '</ol>';
	currentlevel --;
}

入れ子が終わった後に、次の大見出しに入る時には先ほどの<ol class=”chapter”>を閉じないといけません。現在地のレベルが見出しのレベルよりも大きかった時は入れ子が終了しているので、</ol>で閉じてcurrentlevelから1を引きます。

簡単にまとめると…
  • 見出しのレベルが高い:<ol class=”chapter”>追加、現在地レベル + 1
  • 現在地と見出しレベルが同じ:何もしない
  • 現在地レベルが高い:</ol>を追加、現在地レベル – 1

これで入れ子構造の目次が完成しました!

ステップ2:スクロールに連動してカレント表示を切り替える

ようやく本題に入ります。先ほど作った入れ子の目次をスクロールに連動して、今見ている見出しの箇所に背景色を付けて分かりやすくしてみます。

下記サンプルでは、右上の codepen を押すと別窓で表示されます。

See the Pen jQuery table of contents by tokumewi (@tokumewi) on CodePen.


$(function() {	
	/* -------------------------------------------------------
		記事の見出しから目次作成
	--------------------------------------------------------*/
	function makeMokuji() {
		
		var idcount = 1;  // IDのカウント
		var mokuji = '';  // 目次のHTML格納場所
		var currentlevel = 0  // 見出しのレベル初期値
		
		// 見出しを回してリストに格納
		$('article h2, article h3, article h4').each(function(i){
			
			// 見出しごとにIDを保存
			this.id = 'chapter-' + idcount;
			idcount ++;
			
			// 見出しのレベル設定
			var level = 0;
			if(this.nodeName.toLowerCase() == 'h2') {
				level = 1;
			} else if(this.nodeName.toLowerCase() == 'h3') {
				level = 2;
			} else if(this.nodeName.toLowerCase() == 'h4') {
				level = 3;
			}
      
			// 見出しのレベルが現在のレベルよりも数値が大きければ
			// <ol>を追加して入れ子にする
			while(currentlevel < level) {
				mokuji += '<ol class="chapter">';
				currentlevel ++;
			}
      
			// そうでなければ</ol>で閉じて入れ子を終了する
			while(currentlevel > level) {
				mokuji += '</ol>';
				currentlevel --;
			}
			
			// リストを生成
			mokuji += '<li><a href="#' + this.id + '">' + $(this).html() + '</a></li>\n';
		});
		
		// 現在のレベルが0より上ならリストを閉じる
		while(currentlevel > 0) {
			mokuji += '</ol>';
			currentlevel --;
		}	
				
		// HTML出力
		strMokuji = '<h4>目次で流し読みする <span class="kao">・*・:≡( ε:)</span><span class="closeBtn"><i class="fa fa-times-circle-o"></i></span></h4>\
					 <div class="mokujiInner">'
						+ mokuji +
					 '<!-- /.mokujiInner --></div>';
					
		$('.mokuji').html(strMokuji);
		
		/* -------------------------------------------------------
			リストクリックでスムーズスクロール
		--------------------------------------------------------*/
		$('.mokuji li').click(function(){
			var speed = 800;
			var href = $(this).find('a').attr('href');
			var target = $(href == '#' || href == '' ? 'html' : href);
			var position = target.offset().top;
			$('html, body').stop().animate({scrollTop:position}, speed, 'easeInOutCirc');
			return false;
		});
		/* -------------------------------------------------------
			目次のアコーディオン
		--------------------------------------------------------*/
		$('.mokuji ol').prev().addClass('accordion').wrapInner('<div class="inner clearfix"></div>').append('<span class="accBtn"><i class="fa fa-plus-square-o"></i></span>');
		
		// 開閉ボタンを押した時
		$('.accBtn').click(function(){
			
			// 開閉処理
			$(this).parents('li').next().stop().slideToggle(300, 'easeInOutCirc');
			
			// 閉じるボタンアイコン切替
			$('.closeBtn').removeClass('active').addClass('active');
			
			// アイコン切替
			if( $(this).find('i').hasClass('fa-plus-square-o') ){
				$(this).find('i').removeClass('fa-plus-square-o').addClass('fa-minus-square-o');
			} else {
				$(this).find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square-o');
			}
			return false;
		});
		
		// 一括開閉ボタンの表示切替
		var closeBtnRemoveFlag = true;
		$('.mokuji li').each(function() {
			if( $(this).hasClass('accordion') ) {
				closeBtnRemoveFlag = false;
			}
		});
		if( closeBtnRemoveFlag) {
			$('.closeBtn').hide();
		}
		
		// 一括開閉ボタンを押した時
		$('.closeBtn').click(function(){
			
			// アイコン切り替え
			$(this).toggleClass('active');

			// classの有無を確認
			if( $(this).hasClass('active') ){
				$('.mokuji ol ol').stop().slideDown(300, 'easeInOutCirc');
				$('.accBtn').find('i').removeClass('fa-plus-square-o').addClass('fa-minus-square-o');

			} else {
				$('.mokuji ol ol').stop().slideUp(300, 'easeInOutCirc');
				$('.accBtn').find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square-o');
			}

		});
		
		/* -------------------------------------------------------
			カレント表示切替
		--------------------------------------------------------*/
		var secTopArr = new Array();
		secTopArr.length = 0;
		var current = -1;
	
		// 見出しの座標を取得
		$('article [id^="chapter"]').each(function(i){
			secTopArr[i] = $(this).offset().top;
		});
	
		//スクロールイベント
		$(window).on('load scroll',function(){
			for (var i = secTopArr.length-1; i>=0; i--) {
				if ($(window).scrollTop() > secTopArr[i] - 20) {
					$('.mokuji li').removeClass('current').eq(i).addClass('current');
					$('.mokuji ol ol li.current').parent('ol').prev().addClass('current');
					break;
				}
			}
		});
	}
	makeMokuji();

	/* -------------------------------------------------------
		目次固定
	--------------------------------------------------------*/
	function fixedSide() {
		
		// ウィンドウ幅・人気記事を取得
		var w = window.innerWidth;
		var mainH = $('#main').height();
		var sideH = $('#side').height();
		var fixedElm = '';
		
		if(mainH > sideH) { // サイドバーより長ければ
			
			// 固定する要素
			fixedElm = $('.mokuji');
							
			// 要素の位置を取得
			var fixedSideTop = fixedElm.offset().top;
			
			$(window).scroll(function(){

				// スクロール位置を取得
				y = $(window).scrollTop();
				
				// スクロールがサイドバーを上回ったら
				if(y > fixedSideTop){
					fixedElm.addClass('fixed-side');
				} else {
					fixedElm.removeClass('fixed-side');
				}
			});
		}
	}
	fixedSide();
});

70行目以降を追記しています。先ほどと同じようにブロックごとに詳しく見ていきましょう。

目次のアコーディオン

アコーディオン部分の仕様はこんな感じにしました。

  • リストが入れ子になった時に開閉ボタンを表示
  • 開閉ボタンを押すとアコーディオンメニューのように入れ子になったリストが表示される。
  • その動きに合わせてボタンの+と-を切替
  • 見出し横に一括で開閉するボタンを設置する
  • 一括開閉ボタンは押す度にアイコンを回転させる

開閉ボタンのアイコンはいつも使っているFont Awesomeを使用しています。簡単に使えて数も豊富なのでオススメです。使うときはcdnから読み込むか、アイコンフォントをダウンロードして自分のサーバーに設置します。


<link rel="stylesheet" type="text/css" href="http://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">

上記のCSSを一行読むだけで使えるcdnのほうが気軽に使えると思います。他サイトで使われていたらキャッシュとかしてくれそうだし。現在最新は4.4.0ですが、何故かうちの環境で重かったのであえて4.3使ってます…。


// HTML出力(開閉ボタンあり)
strMokuji = '<h4>目次で流し読みする <span class="kao">・*・:≡( ε:)</span><span class="closeBtn"><i class="fa fa-times-circle-o"></i></span></h4>\
			 <div class="mokujiInner">'
				+ mokuji +
			 '<!-- /.mokujiInner --></div>';
			 
		$('.mokuji').html(strMokuji);

/* -------------------------------------------------------
	目次のアコーディオン
--------------------------------------------------------*/
$('.mokuji ol').prev().addClass('accordion').wrapInner('<div class="inner clearfix"></div>').append('<span class="accBtn"><i class="fa fa-plus-square-o"></i></span>');

// 開閉ボタンを押した時
$('.accBtn').click(function(){
	
	// 開閉処理
	$(this).parents('li').next().stop().slideToggle(300, 'easeInOutCirc');
	
	// 閉じるボタンアイコン切替
	$('.closeBtn').removeClass('active').addClass('active');
	
	// アイコン切替
	if( $(this).find('i').hasClass('fa-plus-square-o') ){
		$(this).find('i').removeClass('fa-plus-square-o').addClass('fa-minus-square-o');
	} else {
		$(this).find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square-o');
	}
	return false;
});

// 一括開閉ボタンの表示切替
var closeBtnRemoveFlag = true;
$('.mokuji li').each(function() {
	if( $(this).hasClass('accordion') ) {
		closeBtnRemoveFlag = false;
	}
});
if( closeBtnRemoveFlag) {
	$('.closeBtn').hide();
}

// 一括開閉ボタンを押した時
$('.closeBtn').click(function(){
	
	// アイコン切り替え
	$(this).toggleClass('active');

	// classの有無を確認
	if( $(this).hasClass('active') ){
		$('.mokuji ol ol').stop().slideDown(300, 'easeInOutCirc');
		$('.accBtn').find('i').removeClass('fa-plus-square-o').addClass('fa-minus-square-o');

	} else {
		$('.mokuji ol ol').stop().slideUp(300, 'easeInOutCirc');
		$('.accBtn').find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square-o');
	}

});

一括開閉ボタンを追加するために出力用HTMLに.closeBtnを追記しています。

この辺りはHTMLと一緒に見ないと何言ってんのか分かりにくいので、生成された目次のHTMLを元に解説します。jsで生成した目次のhtmlはこんな感じで出力されています。


<div class="mokuji">
	<h4>目次で流し読みする <span class="kao">・*・:≡( ε:)</span><span class="closeBtn"><i class="fa fa-times-circle-o"></i></span></h4>
	<ol class="chapter">
		<li class="accordion current">
			<div class="inner clearfix">
				<a href="#chapter-1">タイトル1</a><span class="accBtn"><i class="fa fa-plus-square-o"></i></span>
			</div>
		</li>
		<ol class="chapter">
			<li><a href="#chapter-1-1">タイトル1-1</a></li>
			<li><a href="#chapter-1-2">タイトル1-2</a></li>
			<li><a href="#chapter-1-3">タイトル1-3</a></li>
		</ol>
	</ol>
<!-- /.mokuji --></div>

開閉ボタンを作成


$('.mokuji ol').prev().addClass('accordion').wrapInner('<div class="inner clearfix"></div>').append('<span class="accBtn"><i class="fa fa-plus-square-o"></i></span>');

まずはアコーディオンのトリガーとなる開閉ボタンを作成します。セレクタの指定がややこしくて申し訳ないですが1つずつ解説します。

解説1

  • ①目次が入れ子になっている箇所$('.mokuji ol')
  • ②ひとつ前の要素(<li>).prev()に対して
  • .accordionというclassを付けています。
  • ④アコーディオンリストの中身を.wrapInner()を使ってインナーで囲みます。
  • ⑤最後にアコーディオン用のボタンアイコンを.append()を使って要素の最後に挿入しました。

ボタンを押した時の開閉処理


// 開閉ボタンを押した時
$('.accBtn').click(function(){
	
	// 開閉処理
	$(this).parents('li').next().stop().slideToggle(300, 'easeInOutCirc');
	
});

解説2

  • ①開閉ボタンを押すと
  • ②押した要素$(this)の親(.inner)の親<li class=”accordion”>の
  • ③次の要素.next()
  • ④現在の開閉処理を止めて.stop()
  • ⑤スライドで開閉.slideToggle()します。
親要素を参照する.parent()と.parents()の違い
指定した要素の1つ上の親要素を参照するには.parent()を使用します。直近の親要素よりも更に上の要素を参照するには.parents(‘.hoge’)で参照可能です。

アイコン切り替え


// 開閉ボタンを押した時
$('.accBtn').click(function(){
	
	// 閉じるボタンアイコン切替
	$('.closeBtn').removeClass('active').addClass('active');
	
	// アイコン切替
	if( $(this).find('i').hasClass('fa-plus-square-o') ){
		$(this).find('i').removeClass('fa-plus-square-o').addClass('fa-minus-square-o');
	} else {
		$(this).find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square-o');
	}
	return false;
});

解説3

  • ①開閉ボタンを押すと.closeBtnのアクティブを外して再び付ける
  • ②押した要素$(this)のアイコン.find('i')の中に
  • アイコンのclassが付いていたときは、アイコンのclassに切り替える
  • ④そうでなければアイコンのclassをアイコンのclassに置き換える

一括開閉ボタンの動作


// 一括開閉ボタンの表示切替
var closeBtnRemoveFlag = true;
$('.mokuji li').each(function() {
	if( $(this).hasClass('accordion') ) {
		closeBtnRemoveFlag = false;
	}
});
if( closeBtnRemoveFlag) {
	$('.closeBtn').hide();
}

最初にcloseBtnRemoveFlagをtrueに設定し、リストの中に1つでもアコーディオンがあればfalseにします。ひとつもアコーディオンが無かった時は一括開閉ボタンを非表示にします。


// 一括開閉ボタンを押した時
$('.closeBtn').click(function(){
	
	// アイコン切り替え
	$(this).toggleClass('active');

	// classの有無を確認
	if( $(this).hasClass('active') ){
		$('.mokuji ol ol').stop().slideDown(300, 'easeInOutCirc');
		$('.accBtn').find('i').removeClass('fa-plus-square-o').addClass('fa-minus-square-o');

	} else {
		$('.mokuji ol ol').stop().slideUp(300, 'easeInOutCirc');
		$('.accBtn').find('i').removeClass('fa-minus-square-o').addClass('fa-plus-square-o');
	}

});

一括開閉ボタンを押した時にボタンのclassを切り替えます。通常時はアイコンを右に45℃回転させてボタンをの形に変化させます。


.mokuji h4 .closeBtn {
    cursor: pointer;
    position: absolute;
    top: 11px;
    right: 12px;
    font-size: 1.5em;
    -webkit-transition: .2s;
    -moz-transition: .2s;
    transition: .2s;
    -webkit-transform: rotate(45deg);
    -moz-transform: rotate(45deg);
    -ms-transform: rotate(45deg);
    -o-transform: rotate(45deg);
    transform: rotate(45deg);
    -webkit-transform-origin: center;
    -moz-transform-origin: center;
    -ms-transform-origin: center;
    -o-transform-origin: center;
    transform-origin: center;
}
.mokuji h4 .closeBtn.active {
    -webkit-transform: rotate(0deg);
    -moz-transform: rotate(0deg);
    -ms-transform: rotate(0deg);
    -o-transform: rotate(0deg);
    transform: rotate(0deg);
}

後はアイコンに付いているclassに合わせて、アコーディオン部分を閉じるか開くかを分岐させれば完成です!
この時にアイコンも一緒に変更しておきます。

カレント表示切替


/* -------------------------------------------------------
	カレント表示切替
--------------------------------------------------------*/
var secTopArr = new Array();
secTopArr.length = 0;
var current = -1;

// 見出しの座標を取得
$('article [id^="chapter"]').each(function(i){
	secTopArr[i] = $(this).offset().top;
});

//スクロールイベント
$(window).on('load scroll',function(){
	for (var i = secTopArr.length-1; i>=0; i--) {
		if ($(window).scrollTop() > secTopArr[i] - 20) {
			$('.mokuji li').removeClass('current').eq(i).addClass('current');
			$('.mokuji ol ol li.current').parent('ol').prev().addClass('current');
			break;
		}
	}
});

次にカレント表示切り替えです。ここまで来たらあと少しなので、もう少しだけお付き合い下さい…!


var secTopArr = new Array();
secTopArr.length = 0;
var current = -1;

まずは必要な変数を用意しておきます。secTopArrは見出しごとの縦位置を格納するための配列です。secTopArr.length = 0;と指定することで値をリセットできます。

見出しの座標を取得


// 見出しの座標を取得
$('article [id^="chapter"]').each(function(i){
	secTopArr[i] = $(this).offset().top;
});

chapterというIDが付いた見出しを一巡し、それぞれの縦位置を配列に格納していきます。

このスクロール位置を取得するタイミングが重要で、画像やiframeなどの読み込みが終わる前に実行すると、座標位置がずれて正しい位置で現在地表示が行えなくなることがあります。.setTimeout()などを使って、「ページの読み込みから○秒後に実行する」ようにする必要があります。


setTimeout(function(){
	makeMokuji();
},1000);

スクロールイベント・カレント切替


//スクロールイベント
$(window).on('load scroll',function(){
	~ココに処理を書く~
});

スクロール時に何かを実行するにはこのように書きます。.on(‘load scroll)とすることでページ読み込み時にも同じ処理が実行されます。


for (var i = secTopArr.length-1; i>=0; i--) {
	if ($(window).scrollTop() > secTopArr[i] - 20) {
		$('.mokuji li').removeClass('current').eq(i).addClass('current');
		$('.mokuji ol ol li.current').parent('ol').prev().addClass('current');
		break;
	}
}

スクロールする度にfor文で配列を参照し、スクロール位置が見出しの位置を超えたらカレント表示を切り替えています。「-20」の値は任意で変えて下さい。

カレント表示を切り替えるにはリストタグから.currentを外し、新しいカレント.eq(i)に対してclassを付与し直しています。

現在地が入れ子になっている場合は、.currentが付いた要素の親(<ol class=”accordion”>)の1つ前のリストにも同じclassを付けます。

目次固定


/* -------------------------------------------------------
	目次固定
--------------------------------------------------------*/
function fixedSide() {
	
	// ウィンドウ幅・人気記事を取得
	var w = window.innerWidth;
	var mainH = $('#main').height();
	var sideH = $('#side').height();
	var fixedElm = '';
	
	if(mainH > sideH) { // サイドバーより長ければ
		
		// 固定する要素
		fixedElm = $('.mokuji');
						
		// 要素の位置を取得
		var fixedSideTop = fixedElm.offset().top;
		
		$(window).scroll(function(){

			// スクロール位置を取得
			y = $(window).scrollTop();
			
			// スクロールがサイドバーを上回ったら
			if(y > fixedSideTop){
				fixedElm.addClass('fixed-side');
			} else {
				fixedElm.removeClass('fixed-side');
			}
		});
	}
}
fixedSide();

最後にサイドバーの固定切り替えを行います。先ほどと同じようにスクロール位置に応じてサイドバーを固定/固定解除を切り替えます。


// ウィンドウ幅・人気記事を取得
var w = window.innerWidth;
var mainH = $('#main').height();
var sideH = $('#side').height();
var fixedElm = '';

最初に必要な要素をまとめて書いておきます。wはウィンドウ幅、mainHsideHはメインカラムとサイドバーの高さが数値で入ります。


if(mainH > sideH) { // サイドバーより長ければ
	
	// 固定する要素
	fixedElm = $('.mokuji');
						
	// 要素の位置を取得
	var fixedSideTop = fixedElm.offset().top;
		
	$(window).scroll(function(){
		// スクロール位置を取得
		y = $(window).scrollTop();
		
		// スクロールがサイドバーを上回ったら
		if(y > fixedSideTop){
			fixedElm.addClass('fixed-side');
		} else {
			fixedElm.removeClass('fixed-side');
		}
	});
}

もしもメインカラムよりもサイドバーの方が長ければ、固定する要素を定義(ここでは.mokuji)して要素の位置を取得します。

スクロールするごとに現在地が固定する要素の位置を超えるか監視して、もし超えたらサイドバーを固定、超えなかったら固定解除を行います。

サイドバーがフッターに重ならないように、以下の様なCSSを適用すると目次がフッターの下に潜り込むようになります。


footer {
	position:relative;
	z-index:2;
}

まとめ

本当に長くなってしまいましたが、1つずつ手順を追って確認することでjQueryの理解が深まると思います。

jQueryのいいところはブログサービスに依存しないところです。jsが動かせる環境であればWordPressに限らず、はてなブログなどでも利用できます。

ご自由にカスタマイズして素敵な目次を作ってみてください! 最後までご覧頂きありがとうございました。

鹿
writer : 鹿
このブログを管理している鹿。Webデザインとガジェットが好き。
  • Feedly(RSS)で
    ブログを購読してみる
    購読する
  • Push7(プッシュ通知)で
    ブログを購読する
    購読する