4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IntersectionObserverを使って「読んでいるセクションがハイライトされる目次」を実装する

Last updated at Posted at 2024-06-01

はじめに

「ページをスクロールすると、現在画面に表示されているセクションがハイライトされる目次」を実装してみます。
ちょうどQiitaの右側にもあるやつですね。

成果物

toc-hightlighter.gif

以下のリンクから、この記事を読んで具体的にどのような目次が作れるのか確認できます。

前提

メインコンテンツ
<article>
  <!-- セクション1 -->
  <h2 id="heading-1">見出し1</h2>
  <p>本文1</p>
  <p>本文2</p>
  <!-- セクション1 ここまで -->

  <!-- セクション2 -->
  <h3 id="heading-2">見出し2</h3>
  <p>本文3</p>
  <p>本文4</p>
  <!-- セクション2 ここまで -->
</article>
目次
<ul id="toc-container">
  <!-- 以下のようにアクティブなセクションに対応するaタグにクラスを追加するなどしたい -->
  <li><a href="#heading-1" class="active">見出し1</a></li>
  <li><a href="#heading-2">見出し2</a></li>
</ul>

監視対象となるHTMLは、メインコンテンツ のような形式を想定しています。
監視する要素の表示状態が変化するたびに「アクティブなセクション」を判定し、目次のaタグにクラスやattributeを追加・削除したい、というのがこの記事でやりたいことです。

1. 「画面上部から x% の位置にある見出しをアクティブとする」実装

IntersectionObserver の概要

まずはこの記事でメインに扱う IntersectionObserver について簡単に説明します。

IntersectionObserver の一番シンプルな使い方を見てみます。
コンストラクタの第1引数に、監視対象(ターゲット)の表示状態(正確には交差状態と呼ばれています)が変化したときに実行したい処理を記述します。
以下では、ターゲットが表示されているかどうかを console.log で出力します。

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    console.log(entry.isIntersecting ? "表示されています" : "非表示です");
    // isIntersecting以外に、以下のプロパティが利用できます
    // entry.boundingClientRect
    // entry.intersectionRatio
    // entry.intersectionRect
    // entry.isIntersecting
    // entry.rootBounds
    // entry.target
    // entry.time
  });
});

IntersectionObserver をインスタンスを作成したのちに、監視したい要素を observe() で登録します。
複数の要素を登録可能です。

const target = document.querySelector('#target')
observer.observe(target)

IntersectionObserver のコンストラクタには、第2引数でオプションを渡すこともできますが上のコードでは指定していません。
未指定の場合、ビューポート(ウィンドウに現在表示されている領域)とターゲットの交差状態を検出しますので、「ターゲットが画面に表示されているかどうか」を検出できます。

IntersectionObserver の検出領域を線状にする

次は IntersectionObserver にオプションを渡して、検出領域を変更してみましょう。
rootMargin を指定すると、検出領域を拡大・縮小することができます。
CSSの margin のような指定が可能です。

topとbottomの組み合わせで、検出領域を矩形ではなく、線状にすることができます。
これを利用し以下のように指定すると、画面の上からちょうど10%の位置にある要素を検出することができます。

const observer = new IntersectionObserver(callback, {
  rootMargin: "-10% 0 -90%", // 上、左右、下
});

実装

ここまでの内容を使って、「画面上部から x% の位置にある見出しをアクティブとする目次」を実装してみます。

// 監視対象を取得
const observedElements = document
  .querySelector("article")
  .querySelectorAll("h1, h2, h3, h4, h5, h6");

// 目次に含まれるアンカー要素を取得
const anchorLinks = document
  .querySelector("#toc-container")
  .querySelectorAll("a");

// アクティブなセクションの見出しidを保持する変数
let activeSectionHeadingId = "";

const observer = new IntersectionObserver(
  (entries) => {
    const intersectingHeading = entries.find((heading) => heading.isIntersecting);
    if (!intersectingHeading) return;

    activeSectionHeadingId = intersectingHeading.target.id;

    // アンカー要素の状態を更新
    anchorLinks.forEach((anchorLink) => {
      if (anchorLink.hash === "#" + activeSectionHeadingId) {
        anchorLink.setAttribute("aria-current", "true");
      } else {
        anchorLink.removeAttribute("aria-current");
      }
    });
  },
  {
    rootMargin: "-10% 0px -90%",
  }
);

observedElements.forEach((observedElement) => {
  observer.observe(observedElement);
});

完成です!

デモ: https://iwmy10.github.io/active-anchor-toc/1_percentage-simple/
ソース: https://github.com/iwmy10/active-anchor-toc/blob/main/src/1_percentage-simple/active-anchor.ts

【問題点】 スクロールの方向によってアクティブなセクションが切り替わる位置が変わる

この実装では、「上から下に」スクロールした場合と、「下から上に」スクロールした場合とで、アクティブなセクションが切り替わるスクロール位置が異なります。
上のデモページで挙動を確認してもらうと分かると思います。

この実装でも違和感を覚える人は少ないと思うのですが(実際、Qiitaの目次はこのような挙動になっています)、次の実装ではこの問題を改善してみます。

2. 「画面上部から x% の位置にある 『セクション』 をアクティブとする」実装

1つ目の実装で挙げた「スクロールの方向によってアクティブなセクションが切り替わる位置が変わる」問題点を改善します。

ここでは、見出し要素だけでなく、セクションのコンテンツ要素を監視対象に含めることで改善しようと思います。

では実装に入ります。

実装

// 監視対象を取得
const observedElements = document
  .querySelector("article")
  .querySelectorAll("h1, h1 ~ *, h2, h2 ~ *, h3, h3 ~ *, h4, h4 ~ *, h5, h5 ~ *, h6, h6 ~ *");

後続兄弟結合子 - CSS: カスケーディングスタイルシート | MDN を使って、自分より前に見出し要素が存在する p タグなどを取得するようにしました。
次のようなマークアップの場合、h2#heading-1, p#content-1, p#content-2 が取得されます。

<article>
  <p>このpタグは前方に見出し要素が存在しないので一致しません。</p>
  <h2 id="heading-1">見出し1</h2>
  <p id="content-1">本文1</p>
  <p id="content-2">本文2<span>このspanタグは兄弟要素でないので一致しません</span></p>
</article>

次にMap - JavaScript | MDNを使って、各要素と、その要素が含まれるセクションの見出しidを対応付けます。
Object と異なり、要素自体をキーとして利用できるのが便利ですね。

const idByObservedElement = new Map<Element, string>();

let currentHeadingId: string;
observedElements.forEach((element) => {
  const id = element.id;
  if (id) {
    currentHeadingId = id;
  }
  idByObservedElement.set(element, currentHeadingId);
});

前提 のセクションで提示したHTMLの場合、以下のようなMapオブジェクトが作成されることになります。

key(要素) value(見出しid)
<h2 id="heading-1">見出し1</h2> heading-1
<p>本文1</p> heading-1
<p>本文2</p> heading-1
<h3 id="heading-2">見出し2</h3> heading-2
<p>本文3</p> heading-2
<p>本文4</p> heading-2

ここまでで、各監視要素に見出しidが対応付けられたので、あとは 1つ目の実装 と同じ流れになります。

const observer = new IntersectionObserver(
  (entries) => {
    const intersectingElement = entries.find((heading) => heading.isIntersecting);
    if (!intersectingElement) return;

    // ここが変更箇所。要素自体をキーとして、見出しidを取得する
    activeSectionHeadingId = idByObservedElement.get(intersectingElement.target) ?? "";

    // 略
  },
  { rootMargin: "-10% 0px -90%" }
);

2つ目の実装、完成です!

デモ: https://iwmy10.github.io/active-anchor-toc/2_percentage/
ソース: https://github.com/iwmy10/active-anchor-toc/blob/main/src/2_percentage/active-anchor.ts

【補足】 querySelectorAll で取得できる要素の順番について

ここでquerySelectorAll の戻り値のリストの並び順について補足しておきます。

なぜかというと、 Map オブジェクトを使って、各要素とその要素が含まれるセクションの見出しidを対応付ける処理を書きましたが、 querySelectorAll で取得できる要素リストがしっかり上から順番にソートされていないとうまく機能しないからです。

結論としては、ドキュメント順に取得できることが仕様で決まっていました。

querySelectorAll()メソッドは、コンテキスト ノードサブツリー内の一致するElementノードをすべて含むノードリストドキュメント順に返さなければなりません。
- https://www.w3.org/TR/selectors-api/#findelements を翻訳

ドキュメント順とは「行きがけ順深さ優先探索(depth-first pre-order traversal)」のことだそうです。
詳しくないのですが、htmlファイルの1行目から、開始タグが出てきた順番で取り出すようなイメージと考えてよさそうです。
以上より、 querySelectorAll で要素を取得した後に自前でソートし直す必要はありません。

3. 「画面上部から x px の位置にあるセクションをアクティブとする」実装1

ここからは、画面の上から x % の位置ではなく、 x px の位置に指定する実装をしていきます。
それ以外の機能は同じなのでここで読み終えてもらっても良いと思います笑
興味がある方はもう少しお付き合いいただけると幸いです。

さて px での位置指定ですが、画面上部に固定で表示されるヘッダーがあるサイトに最適だと思います。
% での位置指定ではウィンドウサイズによってヘッダに判定線が被ってしまうことがあります。

IntersectionObserver で、上部から x px の位置に線状に領域を設定するのは難しい

ここまでの2つの実装では、 rootMargin: "-10% 0px -90%" を指定することで、画面の上部からちょうど10%の位置にある要素を検出することができました。
今回は px で指定したいので次のようなイメージです。

new IntersectionObserver(callback, {
  rootMargin: "-100px 0 calc(100px - 100vh)", // 上、左右、下
});

しかし、rootMarginは、 px または % での指定しか受け入れられないので上のコードはエラーになります。
(Google Chromeでは、 rootMargin must be specified in pixels or percent. というメッセージが表示されました)

これをなんとかする必要があります。

実装

rootMarginで calc などが使えない以上、あらかじめ計算しておく必要があります。
以下では、rootMarginのbottomを計算する関数を作り、その実行結果をbottomに代入しています。

const rootMarginTop = 100
const rootMarginBottom = () => document.documentElement.clientHeight - rootMarginTop

const observer = new IntersectionObserver(callback, {
  rootMargin: `-${rootMarginTop}px 0 -${rootMarginBottom()}px`, // 上、左右、下
});
observedElements.forEach((observedElement) => {
  observer.observe(observedElement);
});

注意しなければいけないのはウィンドウサイズの変更です。
ウィンドウのサイズを変更されたときにrootMarginがおかしくなってしまうので、resizeイベントで IntersectionObserver を作り直すようにコードを変更します。

let observer: IntersectionObserver | undefined;

const observe = () => {
  if (observer) observer.disconnect(); // 監視を停止
  observer = new IntersectionObserver(callback, {
    rootMargin: `-${rootMarginTop}px 0px -${getRootMarginBottom()}px`,
  });
  observedElements.forEach((observedElement) => {
    observer!.observe(observedElement);
  });
};
observe();

window.addEventListener("resize", () => {
  observe();
});

3つ目の実装完成です。
これでウィンドウサイズの変更にも対応できました。

ここでは省略していますが、resizeイベントの発火頻度を考慮してobserve関数の実行回数を抑制する処理も書いた方が良いと思います。
以下のデモページとソースコードではその処理も含めています。

デモ: https://iwmy10.github.io/active-anchor-toc/3_px-starlight/
ソース: https://github.com/iwmy10/active-anchor-toc/blob/main/src/3_px-starlight/active-anchor.ts

参考

Astroのドキュメントサイト用フレームワーク Starlight の実装を参考にさせていただきました。

4. 「画面上部から x px の位置にあるセクションをアクティブとする」実装2

おまけで、 「画面上部から x px の位置にあるセクションをアクティブとする」実装をもうひとつ紹介します。

x px の位置に線状に領域を設定するのではなく、別のアプローチを試します。
rootMargin: `-${rootMarginTop}px 0 0` として判定領域を面で取るようにします。
判定領域内に複数の監視要素が存在することになりますが、そのうち一番最初の要素を使ってアクティブなセクションを決めるという方法です。

IntersectionObserverCallback では交差状態が変化した要素しか取得できない

この実装方法では、判定領域内にある最初の監視要素が分からなければならないのですが、ひとつ注意があります。
IntersectionObserverCallback の引数 entries には、まさにそのとき交差状態が変化した要素しか渡されません。
言い換えると、 「領域外 → 領域外」 「領域内 → 領域内」のように状態が変化していない要素は含まれないということです。
すべての監視要素の交差状態を知りたい場合は自前でその情報を保持しておく必要があります。

const observer = new IntersectionObserver((entries) => {
  console.log(entries.length); // -> 1 (まさに交差状態が変化した要素の数)
  console.log(observedElements.length); // -> 6 (監視要素の合計)
});

下のコードでは、 Map を使ってすべての監視要素の交差状態を保持するようにしました。

const observedElements = [
  ...article.querySelectorAll(
    "h1, h1 ~ *, h2, h2 ~ *, h3, h3 ~ *, h4, h4 ~ *, h5, h5 ~ *, h6, h6 ~ *"
  ),
];
// 監視対象の交差状態Map
const visibilityByElement = new Map<Element, boolean>(
  observedElements.map((observedElement) => [observedElement, false])
);

const observer = new IntersectionObserver((entries) => {
  // 交差状態が更新された要素について、visibilityByElementを更新
  entries.forEach((entry) => {
    visibilityByElement.set(entry.target, entry.isIntersecting);
  });
});

実装

const rootMarginTop = 100

const observer = new IntersectionObserver(
  (entries) => {
    // 交差状態が更新された要素について、visibilityByElementを更新
    entries.forEach((entry) => {
      visibilityByElement.set(entry.target, entry.isIntersecting);
    });
    // 判定領域内にある最初の監視要素を取得する
    const firstVisibleElement = [...visibilityByElement]
      .filter(([, value]) => value)
      .map(([key]) => key)[0] as Element | undefined;

    const activeSectionHeadingId = firstVisibleElement
      ? idByObservedElement.get(firstVisibleElement) ?? ""
      : "";

    // 略
  },
  {
    rootMargin: `-${rootMarginTop}px 0px 0px`,
    threshold: [0.0, 1.0],
  }
);

最後の実装、完成です!

デモ: https://iwmy10.github.io/active-anchor-toc/4_px-mdn/
ソース: https://github.com/iwmy10/active-anchor-toc/blob/main/src/4_px-mdn/active-anchor.ts

参考

MDNの実装を参考にさせていただきました。

おわりに

初めての記事投稿です。
記事にまとめるの難しいなあとか、これ需要あるのか…?とか思いながらもなんとか投稿までたどり着きました。

少しでも参考になれば幸いです。
あと、「いいね」ボタン押していただけると嬉しいです!
ここまで見てくださった方、ありがとうございます!

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?