【JavaScript】記事から目次を生成する

電脳備忘録

本記事のソースコードの利用によって生じた損害について、当方は一切の責任を負いません。ご自身の判断と責任のもとで参照・ご利用ください。

この記事は最終更新から2年以上経過しています。

とあるサイトで目次を実装する必要があったのですが、仕様を満たすプラグインを見つけることができなかったのでなんとか自力で実装しました。動作はサンプルをみてもらったほうが早いかな?
目次生成のサンプルはこちら

記事構造はこんな感じ。sectionにはIDを設定、s1、s2、s3・・・といった感じ。

<section class="article-section" id="s1">
        <h2>見出し1-1</h2>
        <div class="header-thumb"><img src="img/header_img.jpg" alt=""></div>
        <h3>小見出し1-1</h3>
        <p>テキスト</p>
        <h4>小見出し1-2</h4>
		<p>テキスト</p>
	  </section>
      <section class="article-section" id="s2">
        <h2>見出し2-1</h2>
		<p>テキスト</p>
		<h3>小見出し2-1</h3>
		<p>テキスト</p>
		<h3>小見出し2-2</h3>
		<p>テキスト</p>
		<h3>小見出し2-3</h3>
		<p>テキスト</p>
		<h3>小見出し2-4</h3>
		<p>テキスト</p>
	  </section>

Javascriptで下記のような目次を自動生成。

<nav id="toc">
	<ul>
		<li><a href="#heading-0-見出し1-1" class="nav-h2 complete">見出し1-1</a></li>
		<li><a href="#heading-0-小見出し1-1" class="nav-h3 complete">小見出し1-1</a></li>
		<li><a href="#heading-0-小見出し1-2" class="nav-h4 complete">小見出し1-2</a></li>
		<li><a href="#heading-1-見出し2-1" class="nav-h2 complete">見出し2-1</a></li>
		<li><a href="#heading-1-小見出し2-1" class="nav-h3 complete">小見出し2-1</a></li>
		<li><a href="#heading-1-小見出し2-2" class="nav-h3 complete">小見出し2-2</a></li>
		<li><a href="#heading-1-小見出し2-3" class="nav-h3 complete">小見出し2-3</a></li>
		<li><a href="#heading-1-小見出し2-4" class="nav-h3 active">小見出し2-4</a></li>
	</ul>
</nav>

大まかな仕様はこんな感じ。

  • 記事から一覧を作成。
  • ページトップに表示されたタイミングで、目次の項目をアクティブにする。
  • ページの上下スクロールに応じて目次の項目のアクティブ、非アクティブを切り替える。
  • 目次をクリックするとスムーススクロールで該当箇所をページトップに表示。
  • ページスクロールと目次の疑似スクロールを連動させる。
<script>

// Smooth scroll function
function smoothScroll(target) {
    const startY = window.pageYOffset;
    const stopY = document.getElementById(target).getBoundingClientRect().top + startY;
    const distance = stopY - startY;
    const duration = 1000; // スクロールの時間(ミリ秒)

    let start = null;
    window.requestAnimationFrame(function step(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;
        window.scrollTo(0, easeInOutQuad(progress, startY, distance, duration));
        if (progress < duration) {
            window.requestAnimationFrame(step);
        }
    });
}

function easeInOutQuad(t, b, c, d) {
    t /= d / 2;
    if (t < 1) return c / 2 * t * t + b;
    t--;
    return -c / 2 * (t * (t - 2) - 1) + b;
}


// Function to create dynamic navigation
function createDynamicNav() {
    const nav = document.getElementById('toc');
    const sections = document.querySelectorAll('.article-section');
  
    const ul = document.createElement('ul');
  
    sections.forEach((section, index) => {
        // Get all headings (h2 to h5) within the section
        const headings = section.querySelectorAll('h2, h3, h4, h5');
    
        headings.forEach((heading) => {
            const headingText = heading.textContent;
            const headingId = heading.id || `heading-${index}-${headingText.replace(/\s+/g, '-')}`;
      
            // If the heading doesn't have an ID, set one
            if (!heading.id) {
                heading.id = headingId;
            }

            // Create a list item and link element
            const li = document.createElement('li');
            const link = document.createElement('a');
            link.href = `#${headingId}`;
            link.textContent = headingText;

            // Add class based on heading level
            link.classList.add(`nav-${heading.tagName.toLowerCase()}`);

            // Initially, set the first link as active
            if (index === 0 && heading.tagName.toLowerCase() === 'h2') {
                link.classList.add('active');
            }

            // Add click event listener to each link for smooth scrolling
            link.addEventListener('click', function(e) {
                e.preventDefault(); // Prevent default link behavior
                const targetId = this.getAttribute('href').substring(1); // Get the target section id
                smoothScroll(targetId); // Scroll smoothly to the target section
            });

            // Append link to list item, and list item to the list
            li.appendChild(link);
            ul.appendChild(li);
        });
    });

    // Append the list to the navigation container
    nav.appendChild(ul);
}

// Function to handle scroll event
function handleScroll() {
    const headings = document.querySelectorAll('.article-section h2, .article-section h3, .article-section h4, .article-section h5');
    const links = document.querySelectorAll('#toc a');


    const toc = document.getElementById('toc');
    const tocUl = toc.querySelector('ul');
    // スクロールバーの位置を調整
    const scrollPercentage = (window.pageYOffset / (document.body.scrollHeight - window.innerHeight)) * 100;
    const maxScroll = tocUl.scrollHeight - tocUl.clientHeight;
    const scrollTo = (scrollPercentage * maxScroll) / 100;
    tocUl.scrollTop = scrollTo;


    let lastActiveIndex = -1;

    headings.forEach((heading, index) => {
        const rect = heading.getBoundingClientRect();
        const link = links[index];

        // Check if heading is in viewport (1/4 of the viewport height or at the top of the viewport)
        if (rect.top <= window.innerHeight / 4 || rect.top <= 0) {
            link.classList.add('active');
            lastActiveIndex = index; // Update the last active index
        } else {
            link.classList.remove('active');
        }
    });

    // Check if the user has scrolled to the bottom of the page
    const scrolledToBottom = (window.innerHeight + window.scrollY) >= document.body.offsetHeight;

    // If scrolled to the bottom, set the last link as active
    if (scrolledToBottom) {
        lastActiveIndex = links.length - 1;
    }

    // Update classes based on the current state
    links.forEach((link, index) => {
        if (index === lastActiveIndex) {
            link.classList.add('active');
            link.classList.remove('complete');
        } else if (index < lastActiveIndex) {
            link.classList.add('complete');
            link.classList.remove('active');
        } else {
            link.classList.remove('active');
            link.classList.remove('complete');
        }
    });
}

// Call the function to create navigation on page load
createDynamicNav();

// Add scroll event listener
window.addEventListener('scroll', handleScroll);
</script>

目次のCSS。表示した要素はアクティブ表示、表示していない要素は非アクティブ表示にする。

#toc ul {
    border-bottom: 1px solid rgba(0,0,0,0);
    flex: 1 1 0%;
    position: relative;
    overflow-y: auto;
    -ms-scroll-chaining: none;
    overscroll-behavior: contain;
    max-height: 600px;
    height: 100%
}

#toc ul::-webkit-scrollbar {
    width: 10px;
    height: 10px
}

#toc ul::-webkit-scrollbar-thumb {
    border-radius: 5px;
    background: #a82b43
}

#toc ul li:last-child a::after {
    content: "";
    display: none
}

#toc ul li a {
    display: flex;
    align-items: center;
    padding: .40625rem .8125rem .40625rem calc(.8125rem + 10px);
    position: relative
}

#toc ul li a::before {
    margin-right: 10px
}

#toc ul li a.nav-h2::before {
    content: "";
    width: 15px;
    height: 15px;
    border: 2px solid #c9c6c5;
    border-radius: 50%;
    display: inline-block
}

#toc ul li a.nav-h2.active::before,#toc ul li a.nav-h2.complete::before {
    border: 2px solid #a82b43
}

#toc ul li a.nav-h3::before,#toc ul li a.nav-h4::before,#toc ul li a.nav-h5::before,#toc ul li a.nav-h6::before {
    content: "";
    display: inline-block;
    margin-left: 2px;
    width: 10px;
    height: 10px;
    border-radius: 100%;
    background: silver
}

#toc ul li a.nav-h3.active::before,#toc ul li a.nav-h3.complete::before,#toc ul li a.nav-h4.active::before,#toc ul li a.nav-h4.complete::before,#toc ul li a.nav-h5.active::before,#toc ul li a.nav-h5.complete::before,#toc ul li a.nav-h6.active::before,#toc ul li a.nav-h6.complete::before {
    background: #a82b43
}
0%