2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WordPressにプラグインなしで目次を自動生成する(style、スクロールアニメーション付き)

Posted at

WordPressの投稿エリアにプラグインなしで実装できる目次機能をつくっていきたいと思います。

##今回作るものの要件

  • h2、h3を読み取って自動的に目次を生成する
  • h3は小見出しとして、h2にぶら下がるように表示する
  • クリックしたらスクロールするアニメーションを付ける

##目次を自動生成するPHPスクリプト
WordPressでご使用中のテーマファイル内のfunctions.phpに以下をコピペします。
コードがごちゃごちゃするのが嫌なのでDOMDocumentを使ってHTMLを構築しています。

functions.php
class PHPDOM
{
    public function __construct()
    {
        $this->doc = new DOMDocument('1.0', 'UTF-8');
    }

    public function setNode(string $tagName, ?string $className = null, ?string $text = null): object
    {
        $node = $this->doc->createElement($tagName, $text);
        if (!empty($className)) {
            $node->setAttribute("class", $className);
        }

        return $node;
    }

    public function a(string $href, ?string $className = null, ?string $text = null): object
    {
        $node = $this->doc->createElement("a", $text);
        if (!empty($className)) {
            $node->setAttribute("class", $className);
        }
        $node->setAttribute("href", $href);

        return $node;
    }

    public function img(string $src, ?string $className = null): object
    {
        $node = $this->doc->createElement("img");
        if (!empty($className)) {
            $node->setAttribute("class", $className);
        }
        $node->setAttribute("src", $src);

        return $node;
    }

    public function generator(object $obj):void
    {
        $this->doc->appendChild($obj);
        echo $this->doc->saveHTML();
    }
}

class TableOfContents extends PHPDOM
{
    public function __construct()
    {
        parent::__construct();
        add_filter('the_content', array($this, 'generate'), 11);
    }

    private function parseContent($content): void
    {
        $this->doc->loadHTML(mb_convert_encoding($content, 'HTML-ENTITIES', 'utf-8'));
        $xpath = new DOMXpath($this->doc);
        $h2s = $xpath->query('//h2');
        $obj = new ArrayObject();
        $i = 1;
        foreach ($h2s as $h2) {
            $query = "//h2[{$i}]/following-sibling::h3[following::h2[.='{$h2s->item($i)->nodeValue}']]";
            if ($h2s->length === $i) {
                $query = "//h2[{$i}]/following-sibling::h3";
            }
            $h3s = $xpath->query($query);
            $obj->append($h2);
            $h2->child = new ArrayObject();
            if ($h3s->length > 0) {
                foreach ($h3s as $h3) {
                    $h2->child->append($h3);
                }
            }
            $i++;
        }
        $this->hds = $obj;
    }

    private function addSpanTag()
    {
        $i = 1;
        foreach ($this->hds as $hd) {
            $newNode = $this->doc->createElement('span', $hd->nodeValue);
            $newNode->setAttribute('id', "toc_index{$i}");
            $hd->replaceChild($newNode, $hd->childNodes->item(0));
            if (!empty($hd->child)) {
                $o = 1;
                foreach ($hd->child as $child) {
                    $newChildNode = $this->doc->createElement('span', $child->nodeValue);
                    $newChildNode->setAttribute('id', "toc_index{$i}-{$o}");
                    $child->replaceChild($newChildNode, $child->childNodes->item(0));
                    $o++;
                }
            }
            $i++;
        }
    }

    private function createTable()
    {
        $root = 'toc';
        $wrap = $this->setNode('section', $root);
        $wrap->appendChild($this->setNode('p', "{$root}__title", '目次'));

        $ul = $this->setNode('ul', "{$root}__list");
        $i = 1;
        foreach ($this->hds as $hd) {
            $tagName = $hd->tagName;
            $li = $this->setNode('li', "{$root}__item");
            $li->appendChild($this->a("#toc_index{$i}", "{$root}__link", $hd->nodeValue));
            if (!empty($hd->child)) {
                $ul2 = $this->setNode('ul', "{$root}__list--child");
                $o = 1;
                foreach ($hd->child as $h3) {
                    $li2 = $this->setNode('li', "{$root}__item--child");
                    $li2->appendChild($this->a("#toc_index{$i}-{$o}", "{$root}__link--child", $h3->nodeValue));
                    $ul2->appendChild($li2);
                    $o++;
                }
                $li->appendChild($ul2);
            }
            $ul->appendChild($li);
            $i++;
        }

        $wrap->appendChild($ul);
        if (!empty($this->hds[1])) {
            $this->hds[0]->parentNode->insertBefore($wrap, $this->hds[0]);
        }
    }


    public function generate($content): string
    {
        if (!is_singular()) {
            return $content;
        }
        $this->parseContent($content);
        $this->addSpanTag();
        $this->createTable();

        return $this->doc->saveHTML();
    }
}
(new TableOfContents());

簡単に解説すると、Xpathを使ってWordPressのContent内を解析し、h2とそのh2の後に出てくるh3を読んでいます。
最後のh2だけ、次に続くh2がないためxPathのqueryを変更しています。

DOMDocumentクラスはどのみちXpathを利用するために必要になるため、クラス化してHTML生成にも利用している感じです。

もし特定のポストタイプで目次が必要ないという場合はgenerate内で条件分岐をしてください。(※$contentを返さないと投稿ページが空になってしまいますので、is_singular()の後に追加するのが良いかと思います。)

##styleを当てる
次にスタイルを当てます。微調整が必要かも。ここらへんの色合いとかは適宜変更してご使用ください。

style.scss
.toc{
    margin-bottom: 1.5rem;
    padding: 1rem;
    background: rgba(44, 153, 181, .04);
    border: 3px solid rgba(64, 105, 144, .2);
    width: 100%;
    box-sizing: border-box;

    &__title {
        border-bottom: 1px solid rgba(0, 0, 0, .2);
        text-align: center;
        font-weight: 900;
        font-size: 1.1rem;
    }

    &__list {
        margin-left: 0;

        &--child {
            margin: .5rem 0 0 4rem;
        }
    }

    &__item {
        counter-increment: toc;
        margin-bottom: 1rem;
        list-style: none;

        &--child {
            font-size: .9rem;
        }

        &::before {
            content: counter(toc);
            background-color: rgba(125, 157, 188, .66);
            width: 1.5rem;
            height: 1.5rem;
            display: inline-flex;
            margin-right: .3rem;
            border-radius: 50%;
            color: #fff;
            align-items: center;
            justify-content: center;
            font-weight: 700;
            font-size: .9rem;
        }
    }

    &__link {
        color: #5f7b96;
        font-weight: 700;

        &--child {
            @extend .toc__link;
        }
    }
}

##スクロールアニメーションをつける
目次がクリックされたときに指定の見出しまでスクロールするアニメーションをJavaScriptでつけます。
ES6形式なので、IE11などへ対応する場合はwebpackなどを使ってES5にトランスパイルして使ってください。

scroll.js
function smoothScroll(range) {
    let position = 0;
    let progress = 0;
    const easeOut = (p) => {
        return p * (4 - p);
    }
    const move = () => {
        progress++;
        position = range * easeOut(progress / 100);
        window.scrollTo(0, position);
        if (position < range) {
            requestAnimationFrame(move);
        }
    }

    requestAnimationFrame(move);
}

document.addEventListener("DOMContentLoaded",()=>{
	const ToC = Array.from(document.querySelectorAll('.toc__list a'));
	ToC.forEach(conts => {
		conts.addEventListener('click', event => {
			event.preventDefault();
			let loc = event.target.getAttribute('href');
			let span = document.getElementById(loc);
			let py = span.parentNode.getBoundingClientRect().top + window.pageYOffset;
			smoothScroll(py);
		})
	})
})


##所管
目次生成系には正規表現を使ったりするケースが多いと思いますが、今回はDOMXpathクラスを使って構築してみました。

さすがにh4まで対応するとqueryがごちゃごちゃしてしまうので、h3以降も目次に表示するような場合にはpreg系の関数を使ったほうが良いかもしれません。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?