ブログみたいにWEB上で長い文章を読ませるときは、いまどこを読んでいるのか教えてくれるような目次があるほうが便利です。
目次をクリックするとそのセクションに飛んでくれるとさらに便利です。
イメージとしてはangular.jpみたいなこれ↓↓とか、Qiitaの横にもあるアレとか。
なんかのライブラリが探せばありそうだけど普通にJavaScript書けば十分そう、ということで作ってみましょう。
HTML構造
サンプル。
目次(Table of contents)になるul要素と、記事の表示領域になるwrapper
(overflow: auto
でスクロールさせる)を作ります。
中身はdivで区切ってありますが特に意味はないです。heightで疑似的に記事の長さを出したり背景色をつけて見やすくするためのものです。
<ul>
<li class="toc" scrollTo="content1">AAAA</li>
<li class="toc" scrollTo="content2">BBBB</li>
<li class="toc" scrollTo="content3">CCCC</li>
<li class="toc" scrollTo="content4">DDDD</li>
</ul>
<div class="wrapper">
<div class="content content1">
<h1>AAAA</h1>
</div>
<div class="content content2">
<h1>BBBB</h1>
</div>
<div class="content content3">
<h1>CCCC</h1>
</div>
<div class="content content4">
<h1>DDDD</h1>
</div>
</div>
CSS
目次上の現在地は.active
クラスで丸印を付けて判別しやすくします。
他はオマケ。
ul {
list-style: none;
}
li {
border-left: solid 1px silver;
padding-left: 1rem;
position: relative;
box-sizing: border-box;
}
.toc {
cursor: pointer;
width: 100px;
}
.toc:hover {
background: whitesmoke;
color: royalblue;
}
.toc:hover:not(.active)::before {
content: '';
position: absolute;
top: 0.5rem;
left: -3px;
border-radius: 50%;
width: 5px;
height: 5px;
background: silver;
}
.toc.active:before {
content: '';
position: absolute;
top: 0.5rem;
left: -3px;
border-radius: 50%;
width: 5px;
height: 5px;
background: skyblue;
}
.wrapper {
width: 400px;
height: 300px;
overflow: auto;
}
.content {
overflow: hidden;
}
.content1 {
width: 100%;
height: 500px;
background: skyblue;
}
.content2 {
width: 100%;
height: 500px;
background: royalblue;
}
.content3 {
width: 100%;
height: 500px;
background: lightblue;
}
.content4 {
width: 100%;
height: 500px;
background: aliceblue;
}
JavaScript
本題。
アプローチとしては以下の通りです。
- 記事中の各セクションの開始位置、終了位置を覚えておく。
- スクロールイベントリスナで、現在のスクロール位置と各セクションの範囲を比較して
.active
クラスを付ける。 - 最後までスクロールしたら最後のセクションに
.active
クラスを付ける。 - 目次をクリックしたら、対応するセクションの開始位置を取得してスクロール位置を変更する。
3
は、セクションの高さがスクロール領域よりも小さい場合にスクロール位置が最後のセクションの範囲に入らないことがあるので、その対策です。
たとえば、スクロール可能なwrapper領域が500pxあったとして、コンテンツの合計heightが2000px、最後のセクションのheightが300pxであった場合、wrapperを最後までスクロールさせても最大1500までしか行けない(1500~2000が表示された状態)ので、単純な比較では最後のセクション(1700~2000)には永遠に入らないことになります。
// これをbody.onLoadとかで動かす
function onLoad() {
const wrapper = document.querySelector('.wrapper'); // ラッパー(スクロール領域)
const contents = document.querySelectorAll('.content'); // 各セクションのコンテンツ
const toc = document.querySelectorAll('.toc'); // 目次(クリックしたらそのセクションにスクロール)
const contentsPosition = [];
contents.forEach((content, i) => {
const startPosition =
content.getBoundingClientRect().top -
wrapper.getBoundingClientRect().top +
wrapper.scrollTop;
const endPosition = contents.item(i + 1)
? contents.item(i + 1).getBoundingClientRect().top -
wrapper.getBoundingClientRect().top +
wrapper.scrollTop
: wrapper.scrollHeight;
contentsPosition.push({ startPosition, endPosition });
});
// スクロール位置に応じてTOCの現在位置を変更する
const calcCurrentPosition = () => {
toc.forEach((item, i) => {
const { startPosition, endPosition } = contentsPosition[i];
item.classList.remove('active');
if (
wrapper.scrollTop + wrapper.getBoundingClientRect().height ===
wrapper.scrollHeight
) {
toc.item(toc.length - 1).classList.add('active');
} else if (
wrapper.scrollTop >= startPosition &&
wrapper.scrollTop < endPosition
) {
item.classList.add('active');
}
});
};
// スクロールイベントリスナを登録
wrapper.addEventListener('scroll', calcCurrentPosition);
// 目次にクリックイベントリスナを登録
toc.forEach((item) => {
item.addEventListener('click', () => {
const destination = event.target.getAttribute('scrollTo');
wrapper.scrollTop =
document.querySelector(`.${destination}`).getBoundingClientRect().top -
wrapper.getBoundingClientRect().top +
wrapper.scrollTop;
});
});
calcCurrentPosition();
}
セクションの位置を求めるのにはElement.getBoundingClientRect()
を利用してます。
Element.getBoundingClientRect()
で指定した要素のビューポート上の位置を取得できるので、wrapper
のtop位置やscrollTopを足し引きして「wrapper内での絶対位置」を計算しています。
各セクションの位置がわかれば、あとはwrapper.scrollTop
との比較で現在地を特定することができます。
Element.getBoundingClientRect() - Web API | MDN
Element.scrollTop - Web API | MDN
目次に対応するセクションを特定するのはscrollTo
みたいなワケワカラン属性で対象クラスを指定させていますが、ちゃんとやるならたぶんid
とかを使う方がいい気がします。
結果
追記:IntersectionObserver APIとElement.scrollIntoView
コメントでJavaScriptのイケてるAPIを教えていただきました。
こういうのがほしかったんです。ありがとうございます。
要素が画面に入ったタイミングなどを検知できる、IntersectionObserverという、まさにドンピシャなAPIがあります。
まだ草案ではあるのですが、IE以外のモダンブラウザでは利用できるので、IEが動作対象外だったり、ポリフィルを入れてよいのであれば、こちらを使うとパフォーマンス高く記述量低く実装できます。
また、特定の要素に対してスクロールするscrollIntoViewというこれまたドンピシャなAPIもあります。
こちらもまだ草案のようですが、IE含むモダンブラウザで動作します。
Intersection Observer API - Web API | MDN
Element.scrollIntoView() - Web API | MDN
これらを使って書き直すとこんな感じ↓↓。
53行だったスクリプトが34行になりました。やったね!
function onLoad() {
const contents = document.querySelectorAll('.content');
const toc = document.querySelectorAll('.toc');
const tocMap = new Map();
// IntersectionObserverでコンテンツの出入りを監視
const intersectCallback = (entries) => {
entries.forEach((element) => {
if (element.intersectionRatio) {
tocMap.get(element.target).classList.add('active');
} else {
tocMap.get(element.target).classList.remove('active');
}
});
};
// wrapperの上辺を現在地の基準点にしたいので、rootMarginで微調整
const options = {
root: document.querySelector('.wrapper'),
rootMargin: '-1px 0px -99% 0px',
};
const observer = new IntersectionObserver(intersectCallback, options);
// コンテンツをIntersectionObserverに登録
contents.forEach((content, i) => {
observer.observe(content);
tocMap.set(content, toc.item(i));
tocMap.set(toc.item(i), content);
});
// 目次にクリックイベントリスナを登録
toc.forEach((item) => {
item.addEventListener('click', (event) => {
tocMap.get(event.target).scrollIntoView();
});
});
}
本題とはあんまり関係ないけどTOCとコンテンツのマッピングをMapにしました。
IntersectionObserverを使うことで「イベントが発生したコンテンツ要素からTOCを求める」ことが必要になりますが、添字でアクセスするのは面倒だったからです。