Featured image of post 【Hugo】任意のテーマにPhotoSwipeの画像ビューワーを追加して、ついでに画像を横並べにする

【Hugo】任意のテーマにPhotoSwipeの画像ビューワーを追加して、ついでに画像を横並べにする

目次

はじめに

このサイトは、HugoというSSG(Static Site Generator・静的サイトジェネレータ)で作られています。

Hugoには素敵なテーマがたくさんあり、好きなものを選んで記事を書くだけで、このブログみたいなサイトを簡単に構築することができます。

Complete List | Hugo Themes

素敵なテーマがたくさんあるのですが、画像ビューワーを搭載しているものが少なく、画像を拡大して見てほしいサイトではちょっと選びにくいこともあります。

(あ、当ブログで採用してるStackというテーマは画像ビューワーを搭載しています。)

そこで、任意のサイトに画像ビューワーを導入できないか試行錯誤した結果、いいかんじに導入できたので、メモとして残します。

期待する動作

Stack(このサイトで現在採用されているテーマ)のような挙動にしたいなあと思いました。

まとめると以下のような感じです。

  1. Markdownで横並びに書いたら実際の画像も横並びにになる
  2. クリックして拡大できる
  3. 左右スライドで前後の画像に移動できる

Markdownでは、![代替テキスト](画像のソース)のように書くと画像を表示できます。

Stackでは、下の書き方1のように書くと、各画像が1枚ずつ横いっぱいに表示されます。書き方2のように書くと、異なるサイズの画像も高さを揃えて横並びになります。

<!-- 書き方1 -->
![alt1](image1.jpg)

![alt2](image2.jpg)

<!-- 書き方2 -->
![alt1](image1.jpg) ![alt2](image2.jpg)

横並びというのは以下のような挙動です。

3:4 4:3 16:9

多くのテーマでは、書き方2のように書いても横並び表示にならないことが多く、サイズの大きい画像を載せる際に画面を覆い尽くしてしまいます。

PhotoSwipe

上記のような画像ビューワーを導入するために使うのが、PhotoSwipeです。PhotoSwipeは、任意のサイトに比較的簡単に画像ビューワーを導入できるライブラリです。

PhotoSwipe: Responsive JavaScript Image Gallery

商用利用可能なライブラリなので、AdSenseに登録しているサイトとかでも安心して使えます。StackもPhotoSwipe(旧バージョン)を採用しています。

あとで説明しますが、最新のv5では、ページの画像の要素や属性をあらかじめ決められた構成にして、ページ読み込み時にPhotoSwipeのスクリプトを走らせることで、自動でいい感じの画像ビューワーになってくれます。

PhotoSwipeを使う準備

PhotoSwipeは、以下のJavaScriptを実行することで準備が完了し、使えるようになります。

import PhotoSwipeLightbox from "https://cdnjs.cloudflare.com/ajax/libs/photoswipe/5.4.0/photoswipe-lightbox.esm.min.js"

const lightbox = new PhotoSwipeLightbox({
    gallery: ".pswp-gallery",  // このセレクタに該当する要素がギャラリーとなる
    children: "a",  // 上に該当する要素の子要素のうち、このセレクタに該当する要素がビューワーの対象になる
    pswpModule: () => import("https://cdnjs.cloudflare.com/ajax/libs/photoswipe/5.4.0/photoswipe.esm.min.js")
})

lightbox.init()

上の場合、ギャラリーとなる親要素にはpswp-galleryというクラス名を付与し、子はa要素ある必要があります。よって、以下のよう構成になっていればいいですね。

<p class="pswp-gallery">
    <a href="画像のソース" target="_blank" data-pswp-width="拡大時の画像の横幅" data-pswp-height="拡大時の画像の高さ">
        <img src="画像のソース" alt="代替テキスト">
    </a>
</p>

おっと、ギャラリーの子要素となるa要素には、data-pswp-widthdata-pswp-height属性が必要です。値はそれぞれ、画像拡大時の横幅と高さ(ピクセル数)を入れます。

lightbox.init()が実行されたとき、上のような構成になっている要素にPhotoSwipeが適用されます。

HTML要素を書き換える

Markdownで以下のように2つの画像を挿入したとします。

![画像1](./img01.jpg) ![画像2](./img02.jpg)

このとき、Hugoでビルドすると、HTMLでは以下のように出力されています。

<p>
    <img src="./img01.jpg" alt="画像1">
    <img src="./img02.jpg" alt="画像2">
</p>

しかし、これではPhotoSwipeがギャラリーに変換してくれないので、以下のような構成に変換したいです。

<p class="pswp-gallery">
    <figure>
        <a href="img01.jpg" target="_blank">
            <img src="img01.jpg" alt="画像1">
        </a>
        <figcaption>画像1</figcaption>
    </figure>
    <figure>
        <a href="img02.jpg" target="_blank" data-pswp-width="拡大時の画像の横幅" data-pswp-height="拡大時の画像の高さ">
            <img src="img02.jpg" alt="画像2">
        </a>
        <figcaption>画像2</figcaption>
    </figure>
</p>

a要素がfigureで囲まれているところがさっきとちょっと違います。Stackでは、altに入れたテキストをfigcaptionで表示しているので、それと同じ挙動にします。

Hugoでは、ビルド時に出力されるHTMLの構成をカスタマイズするのが簡単ではありません。imgの表示はRender Hooksとかいうやつを使えばてきるっぽいのですが、今回はその親要素をいじる必要があるので断念しました(詳しい方教えてください)。

そこで、ページが読み込まれたあとにJavaScriptを走らせることで、要素を書き換えていこうと思います。読み込み完了後に画像がガクっと移動する感じになってしまいますが、ファーストビューに画像を置いとかなければいい話なのでよしとします(適当)。ちなみに、このサイトも同じ方法で画像ビューワーを表示しています。


上記のように要素を書き換えるために、以下のJavaScriptコードを実行します。

/** 画像も含めた要素がすべて読み込まれたら実行する */
window.addEventListener("load", () => {
    const gallerySelector = "div.post-content p:has(img)" // テーマによって変える
    const galleries = document.querySelectorAll(gallerySelector)
    /** 各ギャラリーについて実行する */
    galleries.forEach(gallery => {
        /** ギャラリーとなる要素に pswp-gallery クラスを付与 */
        gallery.classList.add("pswp-gallery")
        const imgs = gallery.childNodes
        const ratios = []
        let smallestRatio = Infinity
        /** ギャラリー内の各画像について実行する */
        imgs.forEach(img => {
            if (img.nodeName !== "IMG") return // 画像でなければスキップする
            /** 画像の本来の横幅と高さを取得する */
            const { naturalWidth: width, naturalHeight: height } = img
            /** 横幅 / 高さ の比率を算出する */
            const ratio = width / height
            ratios.push(ratio)
            /** 最も小さい比率の値を smallestRatio に保存しておく */
            if (smallestRatio > ratio) smallestRatio = ratio
            /** a 要素を作る */
            const a = document.createElement("a")
            a.href = img.getAttribute("src") // 画像のソースと同じにする
            a.target = "_blank"
            a.setAttribute("data-pswp-width", width)
            a.setAttribute("data-pswp-height", height)
            a.appendChild(img.cloneNode())
            /** figcaption要素を作る */
            const figcaption = document.createElement("figcaption")
            figcaption.textContent = img.getAttribute("alt") // 画像の代替テキストを使う
            const figure = document.createElement("figure")
            /** 作った a と figcaption を figcaption に入れる */
            figure.append(a, figcaption)
            /** 既存の img を新しく作った figure に置き換える */
            img.replaceWith(figure)
        })
        const columnsString = ratios.map(ratio => `${ratio / smallestRatio}fr`).join(" ")
        gallery.style.setProperty("grid-template-columns", columnsString)
    })
    // lightbox.init()
})

コードの概要

gallerySelector変数は、本文中のギャラリーに該当するセレクタなので、末尾にp:has(img)が付くと思います。テーマによってHTMLの構成が違うので、出力されているHTMLを見て設定します。

gallery.className.add("pswp-gallery")で、ギャラリーに必要なクラス名を付与しています。

画像をa要素で囲ったり、figcaptionを用意したり、目的の構成になるように書き換えます。

ギャラリー内のすべての画像の高さを揃えるために、各画像の縦横比を求めて、その値をもとにギャラリー部分の横幅の配分を決定しています。

naturalWidth と naturalHeight

画像の本来のピクセル数を表す、img要素のnaturalWidthnaturalHeightプロパティは、ブラウザ上で画像が完全に読み込まれて表示されるまで、正しい値を取得できません。よって、window.addEventListener("load")内に処理を記述することで、すべての画像が読み込まれてから実行します。

縦横比とCSS Gridで高さを揃える

ギャラリー内に、ピクセル数が3000x4000の画像1と、2000x1600の画像2、1920x1080の画像3、があったとします。

それぞれ横幅を高さで割って、比率(すなわち、高さを1としたときの横幅)を求めると、画像1は0.75、画像2は1.25、画像3は1.77…となります。

また、ギャラリー内の各画像の比率のうち、最も小さいものを記録しておきます。今回は画像1の0.75が最も小さいので、これを使います。そして、各比率を最も小さい比率で割ると、最小の画像1が1.00、それ以外の画像は必ず1以上となります。

画像横幅高さ比率最小比率(この場合は0.75)で割ったもの
1300040000.751.00
2200016001.251.66…
3192010801.77…2.37…

これを、CSSのgrid-template-columnsとしてギャラリー要素に適用すると、gird-template-columns: 1fr 1.66fr 2.37fr;となり、横幅が1 : 1.66 : 2.37の画像を配置する場所が作られるわけです。そこに画像をいっぱいに表示することで、ギャラリー内の各画像が高さを揃えていい感じに並んでくれます。

こんな感じ

さっき、最小比率で割ってすべての比率を1以上したのには訳があります。例えば、ギャラリー内に3:4の画像が1枚だけあった場合、比率は0.75となりますね。そのまま使うとgrid-template-columns: 0.75fr;となりますが、これでは画像が横幅の3/4までしか表示されないので困ります。ここで、ギャラリー内の最小比率は0.75なので、0.75 / 0.75 = 1であり、grid-template-columns: 1frとなります。やったー、画像が横いっぱいに表示されるようになりました。だから、最小比率で割る必要があったんですね。

テーマをいじる

上記の内容をもとに、実際にテーマに手を加えていきます。

今回はPaperModというHugoの人気テーマを実験台にします。

Hugoでは、./themes/テーマ名/layouts./themes/テーマ名/staticより下層に置いてあるファイルを、./layouts./staticに同じディレクトリ関係で移してから編集することで、テーマファイルを直接変更することなくテーマをカスタマイズできます。

また、多くの人気テーマでは、custom.htmlのような、我々がいじる用の空のファイルを用意してくれていることが多いです。PaperModでは、layouts\partials\templatesextend-head.htmlextend-footer.htmlといった空のファイルがあるので。これを利用します。

もし、そういったファイルが見当たらない場合は、適当にheadやfooterっぽいものを探して追加してみてください。


実際に追加する内容は以下の通りです。

<style>
    .pswp-gallery {
        display: grid;
        gap: 1%;
    }

    .pswp-gallery img {
        width: 100%;
        height: auto;
    }

    /* PaperModでは必要 */
    .pswp-gallery a {
        box-shadow: none;
    }
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/photoswipe/5.4.0/photoswipe.css">
<script type="module">
    import PhotoSwipeLightbox from "https://cdnjs.cloudflare.com/ajax/libs/photoswipe/5.4.0/photoswipe-lightbox.esm.min.js"

    const lightbox = new PhotoSwipeLightbox({
        gallery: ".pswp-gallery",
        children: "a",
        pswpModule: () => import("https://cdnjs.cloudflare.com/ajax/libs/photoswipe/5.4.0/photoswipe.esm.min.js")
    })

    const gallerySelector = "div.post-content p:has(img)"
    const galleries = document.querySelectorAll(gallerySelector)
    window.addEventListener("load", () => {
        galleries.forEach(gallery => {
            gallery.classList.add("pswp-gallery")
            const imgs = gallery.childNodes
            const ratios = []
            let smallestRatio = Infinity
            imgs.forEach(img => {
                if (img.nodeName !== "IMG") return
                const { naturalWidth: width, naturalHeight: height } = img
                const ratio = width / height
                ratios.push(ratio)
                if (smallestRatio > ratio) smallestRatio = ratio
                const a = document.createElement("a")
                a.href = img.getAttribute("src")
                a.target = "_blank"
                a.setAttribute("data-pswp-width", width)
                a.setAttribute("data-pswp-height", height)
                a.appendChild(img.cloneNode())
                const figcaption = document.createElement("figcaption")
                figcaption.textContent = img.getAttribute("alt")
                const figure = document.createElement("figure")
                figure.append(a, figcaption)
                img.replaceWith(figure)
            })
            const columnsString = ratios.map(ratio => `${ratio / smallestRatio}fr`).join(" ")
            gallery.style.setProperty("grid-template-columns", columnsString)
        })
        lightbox.init()
    })
</script>

後半のscript要素内のコードは、さっき説明したものそのままです。その上部にstyle要素があり、以下のようにスタイルを指定しています。

.pswp-gallery {
    display: grid;
    gap: 1%;
}

.pswp-gallery img {
    width: 100%;
    height: auto;
}

/* PaperModでは必要 */
.pswp-gallery a {
    box-shadow: none;
}

.pswp-galleryはギャラリーとなる要素で、grid-template-columnsを使うためにgridである必要があります。また、ギャラリー内の画像と画像の間にスペースがほしいので、gapを設定しています。

.pswp-gallery imgはギャラリー内の画像です。grid-template-columnsで用意された横幅いっぱいに画像を表示したいのでwidth: 100%;を指定し、それに高さを揃えるためにheight: auto;を指定しています。

また、PaperModでは、すべてのa要素にbox-shadowで下線を引くように指定されています。これが画像も対象にならないようにするために、.pswp-gallery内のa要素については除外するようにしています。

比較

さっきのHTMLを実際に適用して比較してみます。なお、Markdownでは以下のように記述してあります。

![Redmi K30 Pro](img01.jpg) ![OnePlus 9](img02.jpg) ![Xiaomi 13](img03.jpg)

適用前では、以下のように各画像が横いっぱいに表示され、縦にズラーっと並んでしまっていました。

適用前 その1 適用前 その2

適用後は、以下のように画像が横に並び、altに設定したテキストがキャプションとして表示されるようになりました。

また、PhotoSwipeが適用されているので、クリックすると拡大し、スライドで移動できる、いい感じの画像ビューワーになりました。

適用後

めでたしめでたし。

おわりに

「自分のHugo製サイトに画像ビューワーを設置したい」とか、「オシャレなテーマだけど、画像ビューワーが無いから使いづらいな……」みたいな人にとっては、本記事の内容はまあまあ役に立つのではないかなあと思います。

PhotoSwipeがかなり優秀な画像ビューワーライブラリで、思ったより簡単に実装できてしまいました。これで商用利用可能なんて、本当にありがたいことです。


……あと、本記事で「画像ビューワー」という単語が何度も登場しましたが、世の中には「ビューア」、「ビュアー」、「ビュワー」など、様々な表現が存在しています。この記事では「ビューワー」に表記を統一していますが、別に「ビューワー」派閥であるわけではありませんし、他の表現を否定するつもりもありません。あえて言わせてもらうならば、「Viewer」の正しい日本語表記は何だとか、「きのこの山」と「たけのこの里」のどっちがいいとか、「私の住んでいる地域では主に今川焼きと呼ばれている食べ物」の正しい名称はどれとか、そういったくだらない争いに巻き込まれるのが嫌です。

そこで、どうしても気に食わないという方のために、当記事の「ビューワー」表記を「ビュアー」、「ビュワー」、「ビューア」、「ビューワ」、「ビューアー」のいずれかに一括で書き換えることができるボタンを設置しました。これで我慢してください。

あなたのViewerがこの中に存在しないという人、ごめんなさい。自分でUserScriptを書いてください。


記事の内容について何かございましたら、@MateBookerM3までご連絡ください。

以上です、おわり。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。