前回の記事で目次をつくるJSの作成を試みましたが、その際に作れていなかった複数種類のh要素を対象とした目次を作ってみました。
前回以上に穴のある作りですが、何かのおりに一部を転用できるかもしれないため自分用の記録として残したいと思います。
なお、前回の記事は以下からどうぞ。
[追記 2020.9.28]
新しく作り直しました。
実現したいこと
実現したいことは以下の通り。
- JSで目次作成
- 目次として取得する対象はh2h3h4h5
目次として取得する対象としてh要素を複数種類挙げていますが、これはコードの中身を書き替えれば増減が可能です。
コード
以下がサンプルコードですが、「#mainの範囲内で、h2からh5までを目次として取得し、最初のh2の上部に目次を表示させる」という設定になります。
(!function(){
//DOMの構築が完了したら動作開始
document.addEventListener('DOMContentLoaded', myTOC());
function myTOC(){
//目次作成と表示の対象となる範囲を、ラップ要素のIDで指定。 ※必須
var range = "main";
//目次のタイトルを設定。 ※必須
var title = "目次";
//目次を表示する目印となるタグ(指定範囲内に存在する最初のタグの上に目次が表示される)を、取得するh要素から変更したい場合はタグ名を入力。
//※オプション
var tag = "h2";
//IDで指定する場合はこちらを入力。
//IDの指定がない、あるいは、IDで指定した要素がない場合は、タグ指定が優先。
var tagId = "";
//目次から移動した際の位置調整。
//上からの余白を 数値 で指定。pxは不要。
//spanにpadding-topを追加するので、不具合のでる可能性がある。
//※オプション
var space = "";
//スマホサイズでの閲覧時に設定。
//※オプション
var spaceS = "";
//スマホ用の余白を適用するためのトリガーとなる画面サイズを指定。入力しない場合はデフォルトの375pxが適用される。
//※オプション
var display = "";
//目次のタイトルに使うpタグを生成。
var p = document.createElement("p");
//目次のラップ要素を生成。
var wrap = document.createElement("div");
//目次に使うolを生成。
var ol = document.createElement("ol");
//目次に使うliを入れる変数。
var li = "";
//目次に使うaを入れる変数。
var a = "";
//目次に使うIDを入れる変数。
var tocId = "";
//目次に使うアンカーテキストを入れる変数。
var tocStr = "";
//目次のループ内の連番用変数。
var num = "";
//目次対象のh要素の中身を入れる変数。
var hStr = "";
//目次を作るための範囲をIDで指定して取得。
range = document.getElementById(range);
//範囲内に対して、h要素を検索し、連番ID付きspanを挿入した状態に置換する
//連番用カウンタ
var count = 0;
//h2からh5までを検索し、コールバックで処理を行う
range.innerHTML = range.innerHTML.replace(/<h2.*?>.*?<\/h2>|<h3.*?>.*?<\/h3>|<h4.*?>.*?<\/h4>|<h5.*?>.*?<\/h5>)/g,
function(){
count++;
//検索した文字列に <h2 があればh2タグと見なして、内部をspanでラッピング
//spanのクラスはh要素を指定する変わりの目印に使う
switch ( true ){
case /<\h2/.test(arguments[0]):
arguments[0] = arguments[0].replace(/(<h2.*?>)/g, '$1<span class="toc-span" id="toc-' + count + '-h2">').replace('<\/h2>', '</span></h2>');
break;
case /<\h3/.test(arguments[0]):
arguments[0] = arguments[0].replace(/(<h3.*?>)/g, '$1<span class="toc-span" id="toc-' + count + '-h3">').replace('<\/h3>', '</span></h3>');
break;
case /<\h4/.test(arguments[0]):
arguments[0] = arguments[0].replace(/(<h4.*?>)/g, '$1<span class="toc-span" id="toc-' + count + '-h4">').replace('<\/h4>', '</span></h4>');
break;
case /<\h5/.test(arguments[0]):
arguments[0] = arguments[0].replace(/(<h5.*?>)/g, '$1<span class="toc-span" id="toc-' + count + '-h5">').replace('<\/h5>', '</span></h5>');
break;
default:
break;
}
return arguments[0];
});
//対象範囲内のh要素の中のspanをクラスで取得。
hSpan = range.getElementsByClassName('toc-span');
//目次を入れる場所の指定位置を確定。
//優先順位は ID指定 > タグ指定。
tag = range.getElementsByTagName(tag);
tagId = document.getElementById(tagId);
if(tagId){
//目次を入れる目印の要素としてIDが指定されている場合。
tag = tagId;
}
tag = tag[0];
//スマホ画面サイズの余白指定があり、かつ、スマホサイズの画面で閲覧の場合に、指定した数値を設定。
//指定がなければ375pxを設定
if(display && !isNaN(display)){
display = display;
} else {
display = "375";
}
//スマホの画面サイズで
if(spaceS && !isNaN(spaceS) && window.matchMedia( "screen and (max-width:" + display + "px)" ).matches ) {
space = spaceS;
}
//目次から移動後の位置調整に数値があればpadding-topで設定。
var flag = false;
if(space){
flag = true;
} else {
flag = false;
}
//最初のh2の上に目次のラップ要素を追加。
tag.parentNode.insertBefore(wrap , tag);
//ラップ要素にIDを設定
wrap.id = "my-toc";
//ラップ要素の中にタイトルのPタグを入れる。
wrap.appendChild(p);
//目次のタイトルの文言を設定。
p.innerHTML = title;
//目次のタイトルにクラス名を設定。
p.classList.add("my-toc-title");
//olをラップ要素の中に入れる。
wrap.appendChild(ol);
for (var i = 0; i < hSpan.length; i++) {
//構造化マークアップやクラス名に使うために1からはじまる連番を設定。
num = i + 1;
//目次に使うIDを、spanに付けたIDを利用して設定。
tocId = hSpan[i].id;
//h要素の中に挿入したspanを利用して、h要素内のテキストを取得。
hSpanStr = hSpan[i].innerHTML;
//余白に数値設定があればpaddingを指定
if(flag){
hSpan[i].style.paddingTop = space + 'px';
}
//spanの親要素をたどり、h要素のdata属性(data-toc)を取得。
tocStr = hSpan[i].parentNode.getAttribute("data-toc");
if (!tocStr){
//data属性(data-toc)がなければh要素内のspanを目印に、h要素のテキストを取得。
tocStr = hSpanStr;
}
//liを生成。
li = document.createElement("li");
//liにspanのIDを利用したクラスを設定
li.classList.add("item-" + tocId);
//aを生成。
a = document.createElement("a");
//aタグにページ内リンクを設定。
a.setAttribute("href", "#" + tocId);
//aタグにクラスを設定 計測用。
a.classList.add("my-toc-item", "my-toc-item-" + num);
//aタグにh要素のテキストかdata属性の値を設定。
a.innerHTML = tocStr;
//liにaを入れる。
li.appendChild(a);
//liをolの中に入れる。
ol.appendChild(li);
}
//処理完了後にイベント削除。
document.removeEventListener("DOMContentLoaded", myTOC,false);
}
}());
前回とは違い、対象とするh要素の設定項目は作っていません。
表示位置の指定は前回とあまりかわりませんが、以下のいずれかを必ず指定する形になります。
- HTMLタグ指定(目次生成対象の範囲にある指定タグのうち、最初にでてくるものの上に表示させる)
- ID指定
複数のh要素を取得する方法
まず、複数のh要素を取得するというのがどういうことなのかを簡単に書いておきたいと思います。
- 対象範囲内のHTML内にh2からh5までがランダムに配置されている状態を想定
- JSでHTMLタグを取得するgetElementsByTagName()では、h2のみ、h3のみ、というように1種類のタグしか取得できない
- getElementsByTagName()で個別にh2やh3を取得した場合、取得したh要素の出現順序がわからえない(h2だけの順序、h3だけの順序はわかる)
- 出現順序を認識できる状態でランダムに配置されたh2からh5を取得するには、getElementsByTagName()以外の方法を探す必要がある
つまり、JSに用意されているメソッド単体では対応できないため、独自の方法を考える必要があります。
そのため今回は以下の方法での実装を試みています。
- h要素の変わりに、h要素内にspanを入れて利用する
具体的には以下のような流れです。
- 範囲内のHTMLを取得
- 取得したHTMLに対し、replase()で<h2.*?>.*?<\/h2>のように指定して文字列を取得
- 取得した文字列に対し、さらにreplase()でクラスとIDを付与したspanを入れた状態に置換
- 以降はspanのクラスやIDを利用して内容を取得したり操作する
メリットとデメリット
この方法のメリットは、複数のh要素を順番に扱う手がかりを作れるため、後々操作がしやすくなります。
反面、getElementsByTagName()とは違い正規表現で無理矢理タグを特定しているので、予期せぬ事態も想定され、この点は大きなデメリットになります。
その他の方法
その他の方法として、indexOf()の利用ができないかなと思ったのですが、こちらは形にできませんでした…。
対応できなかった表現
h要素には階層構図が存在しますので、リスト要素で表す場合には入れ子構造にするのが一般的かと思います。
しかしながら、この記事のコードではそこまで作ることができていません。
一応、spanに付与した連番IDはh2やh3を識別可能な状態にしていますので、分岐を作る手がかりはありますので、また後ほど気力があれば試してみたいと思います。
装飾
HTMLが入れ子になっていないので、CSSでどうにかするしかない状態です。
一応、当記事ではCSSで装飾を施していますが、微妙な見映えです…。
適正な目次
目次をどの程度まで表示させるかというのは悩みどころではあります。
例えばh2のみなら記事に関する大まかな情報が伝えられますが、h2からh5のように増やせばふやすほど伝える情報がまし、ユーザーは目次を見た段階で読むべき記事なのかが判断しやすくなります。
反面、ページ上部を目次が占めるスペースが際限なく広がり、スマホなどの小さな画面では目次が2画面分という事態もあり得なくもなく。
本文が長ければ長いほど目次の重要性は増しますが、スペースの問題を考えると項目数の多い目次は利便性を損ねかねずです。
個人的には、目次と索引の違いを考えて実装する何らかの方法があればと思っており、一案はあるので今後試してみたいと思います。
開閉式で初期状態で閉じた目次
開閉式にした上で、ページにアクセスした際の初期状態が閉じた状態にすれば、スペース問題は解決可能かもしれません。
反面、開かねばならない目次が使われるのかと言えば、開かれた目次に比べて使われる頻度は下がるのではとも思います。
開閉式が一概にだめという根拠は得ていないので(計測実験はしていませんので数値の裏付けがまったくなく)、閉じた目次の影響は想像するしかない状態ではあります。
結び
作っては見たものの、というのは前回から変わらずです。
機能や柔軟性を求めれば求める程、既に存在する優良なプラグインで実装した方が良いと思います。
1人がこの記事を評価
役に立ったよという方は上の「記事を評価する」ボタンをクリックしてもらえると嬉しいです。
連投防止のためにCookie使用。SNSへの投稿など他サービスとの連動は一切ありません。