目次
サイドメニューを左から右へ
人の目線は上から下、左から右に流れていくものなので、サイドメニューを右側に配置したほうが記事を読む人も集中しやすいのではないか・・・というのと、右側のほうが個人的にも落ち着くので、位置を変えたいと思う。
これについては、Layout.astroのmainタグとasideタグのスタイルを変更することで実現できた。まず、mainとasideにはスタイルでorderが設定されていたが、これを削除した。そして、mainタグとasideタグの親のdivに”flex-direction: row”が適用されうように修正した。
.container > div {
display: flex;
flex-direction: row; /* ここを追加 */
}
@media (max-width: 640px) {
.container > div {
display: block;
}
}
main {
flex: 1;
/* order : 2;*//* ここを削除 */
min-width: 0;
justify-content: space-between;
}
@media (max-width: 640px) {
main {
}
}
aside {
/* order : 1;*//* ここを削除 */
width: 300px;
padding: 20px 20px;
background-color: #f4f4f4;
}
@media (max-width: 640px) {
aside {
width: 100%;
}
} サイドメニューに目次表示
目次コンポーネントを作成
サイドメニューに目次を表示するために、まずは目次のコンポーネントを作成することにした。コンポーネントを一から作成するのは面倒だったので、なにか楽ができる方法がないかと考えた結果、notion-bloks/TableOfContents.astroが流用出来ることに気がついた。TableOfContentsは、Notionで用意したIndexを表現するためのコンポーネントだが、実装を確認するとHeadingのリストからaタグを列挙するというコンポーネントとなっており、大変都合が良いものだった。
今回は、TableOfContents.astroをそのままサイドメニューに埋め込むことでも目次は表示することが出来るが、切り離してカスタマイズが出来るようにと考えて、TableOfContentsOfAside.astroを作成した。
<div client: slot="aside" class={styles.aside} id="aside_navigation">
<div id="table_of_contents"><TableOfContentsOfAside headings={headings}/></div>
<BlogPostsLink
heading="Posts in the same category"
posts={postsHavingSameTag.filter(
(p: interfaces.Post) => p.Slug !== post.Slug
)}
/>
<BlogPostsLink heading="Recommended" posts={rankedPosts} />
<BlogPostsLink heading="Latest posts" posts={recentPosts} />
<BlogTagsLink heading="Categories" tags={tags} />
</div> モバイル表示時に目次を非表示化
モバイル端末で表示したとき、サイドメニューの内容は記事の一番下に表示されるようにデフォルトでなっている。サイドメニューが一番下に移動したとき、目次がそこに表示されてもあまりメリットはないと思ったので、その場合は目次を非表示にするようにした。
#table_of_contents{
}
@media (max-width: 640px) {
#table_of_contents{
display: none;
}
} 目次の表示を固定化
サイドメニューに目次が表示できたものの、記事のスクロールとともに目次も上に消えてしまい、目次の意味をなしていない。サイドメニューに表示される目次はスクロールせずにその場に固定されていることが理想的で、他人のブログでよく見かける目次もやはり固定化されている。ということで、目次の固定化に取り組むことにした。
考えた実現方法は、スクロールと画面のリサイズのイベントをハンドリングし、目次を固定化を判定して、必要に応じてスタイルを切り替える方法を取った。判定処理は、目次の先頭が画面外に出る直前まではスクロールを許し、画面外に出る時に固定判定としている。
<script>
const aside_navigation = document.getElementById('aside_navigation');
let offsetTop = aside_navigation.offsetTop;
function updateContentsTable(){
// ↓この一文がないと、画面の途中でリロードした時に、固定が解除されない。
if( aside_navigation.offsetTop != 0)offsetTop = aside_navigation.offsetTop;
if (window.scrollY >= offsetTop) {
aside_navigation.classList.add('fixed');
} else {
aside_navigation.classList.remove('fixed');
}
}
updateContentsTable();
// 画面サイズとスクロールイベントが発生した時に、コンテンツテーブルを更新する。
['resize', 'scroll'].forEach((eventType) =>{
window.addEventListener(eventType, () => {
updateContentsTable();
})
})
</script> #aside_navigation.fixed {
position: fixed;
right: 20px;
top: 0px;
width: 260px; /* Layout.astroでasideの幅が300px、左右パディングが20pxずつある為、300-20-20=260*/
} 目次の表示位置追従
最後に、現在表示している位置が目次で分かるようにしたい。これには、Intersection Observerを使うと良いという記事を見つけ、これを参考に実装してみた。
Intersection Observerの登録
記事のHeadingの表示を追いかけたいので、h3とh4とh5のタグをすべて抽出して、それらをすべてオブザーバーに登録した。これでスクロールして、タグが画面内の特定領域を出入りした時にイベントが発生するようになる。rootMarginは’top right bottom left’の順に上下左右からどれだけ範囲を拡大/縮小するかを設定する。今回の例ではbottomを-80%としたことから、最下部を80%縮小方向に領域を変更している。
<script>
const observer = new IntersectionObserver((entries) => {
// ここにイベント発生時の処理を書く
}, {
rootMargin: '0px 0px -80% 0px', // ビューポート最上部から20%の範囲に入った時に発火
});
document.querySelectorAll('h3,h4,h5').forEach(el => {
observer.observe(el);
});
</script> 現在位置の強調表示
このイベントの発生を契機として、イベントを発生させた要素と目次のテキストを比較して一致するテキストを強調するようにした。テキストの一致判定では、どうもテキストの左右にスペースが含まれているようで判定が成功しなかったため、trim()を使って空白を削除してやることで、一致判定がうまくいった。
// 目次を構成する要素と、すべて比較。一致したら強調表示を切り替えて終了。
for(const element of elements){
if( element.innerHTML.trim() === headingText.trim() ){
// ボタンの強調を切り替える
switchFocusedElement(element)
return;
}
} その他の微調整
ここまでである程度の位置表示ができるようになったが、初回表示時や目次のリンクから移動した際に、IntersectionObserverが呼ばれないという問題があった。これらの細かい問題を解決するために、初回表示と、リンク移動時に位置表示を更新する処理を追加した。
それらの処理も入れたコードの全体が以下の通り。
<script>
// 記事内のHeader一覧
const headerElements = document.querySelectorAll('.post-body h3, .post-body h4, .post-body h5');
// 表示中Headerの基準Y座標
const baseTopY = window.innerHeight * 0.2;
// フォーカス位置の初期化
function initFocusedElement(){
let tmpHeaderElement = null;
for(const headerElement of headerElements){
// 一番最初のタグを初期位置に設定する。
if(tmpHeaderElement == null){
tmpHeaderElement = headerElement;
}else{
// 2つの要素のY座標取得
let tmpHeadlerTopY = tmpHeaderElement.getBoundingClientRect().top - baseTopY;
let headerTopY = headerElement.getBoundingClientRect().top - baseTopY;
// 基準座標に近い要素を選択する
tmpHeaderElement = Math.abs(tmpHeadlerTopY) < Math.abs(headerTopY) ? tmpHeaderElement : headerElement;
}
}
if( tmpHeaderElement != null){
// <b>や<i>などのタグに、テキストが囲われている場合は、最下要素まで移動する
while( tmpHeaderElement.childElementCount > 0){
tmpHeaderElement = tmpHeaderElement.children[0];
}
// 目次を構成する要素からフォーカスする要素を探し、フォーカスを切り替える。
const headingText = tmpHeaderElement.innerHTML;
if(headingText != null){
for(const element of elements){
if( element.innerHTML.trim() === headingText.trim() ){
switchFocusedElement(element);
return;
}
}
}
}
}
var focusedElement : Element;
//フォーカスの切り替え
function switchFocusedElement(nextElem : Element){
if(focusedElement != null)focusedElement.style.backgroundColor = '';
focusedElement = nextElem;
focusedElement.style.backgroundColor = '#f5deb3';
}
// 目次一覧を取得
const elements = document.getElementsByClassName("table-of-content-aside");
function initEventListener(){
// IntersectionObserverに記事ヘッダーを登録
headerElements.forEach(elem => {
observer.observe(elem);
});
for(const element of elements){
element.addEventListener('click', () => {
switchFocusedElement(element)
});
}
}
// IntersectionObserver作成
const observer = new IntersectionObserver((entries) => {
// イベントを起こしたテキストを取得する。
var headingText = null;
for(var tmpEntry of entries){
if(tmpEntry.isIntersecting) {
var element = tmpEntry.target;
while( element.childElementCount > 0){
// <b>や<i>などのタグに、テキストが囲われている場合は、最下要素まで移動する
element = element.children[0];
}
headingText = element.innerHTML;
break;
}
}
// 目次を構成する要素と、すべて比較。一致したら強調表示を切り替えて終了。
if(headingText != null){
for(const element of elements){
if( element.innerHTML.trim() === headingText.trim() ){
// ボタンの強調を切り替える
switchFocusedElement(element)
return;
}
}
}
}, {
rootMargin: '0px 0px -80% 0px', // 上部に来たときに発火
});
// 初期化
initEventListener();
initFocusedElement();
</script> まとめ
分からないなりに手探りでコードを読み解き、なんとかそれっぽい動きはするようになった。ところが、実はバグがいくつか残っている。
-
記事内の目次から飛んだ場合に更新ができない。 - 高速でスクロールした場合に位置表示がうまく更新できない場合がある。
- Hタグの文言が重複するとタグのidが重複し目次と飛び先が一致しなくなる。
などのバグだ。あまり気になる人もいないと思うので、これらは、後日にのんびりバグ修正していきたいと思う。
(9/18追記)
記事内の目次から飛んだ場合にフォーカスが移動しないバグについて、IntersectionObserverの発火する領域を調整することで、解決することができた。記事内のコードは修正済みのものです。
