ルモーリン

PhotoSwipeでアルバム表示

投稿:2025-04-29

撮り溜めたパチスロの画面を投稿しよう。 せっかくなのでいつもの縮小画面でなく、スマホで撮影した際のサイズのままで投稿したい。 縮小→(クリック)→全画面→(クリック)→拡大、といった遷移や、複数ある写真を切り替えたい。 この機会にそういったJavaScriptライブラリを利用しようと考えました。 できあがったページはスマスロ Re:ゼロから始める異世界生活 season2です。

ChatGPTに聞いていくつかのライブラリを試用、こちらの希望に沿えるのがPhotoSwipeでした。 実際に操作してから自分の希望する挙動が分かってくる所謂「お客さん」状態(笑)。 「この挙動ではなく、こうしたい」とか色々と注文を付けていくうちに「このライブラリでは無理、PhotoSwipeにしましょう」となった次第。

初期のサンプルコードはHTMLのタグに写真へのリンクやサムネイルを貼り、ライブラリが拾って色々と見せる方法です。 ページを表示した際に複数ある写真のダウンロードを一斉に始めてしまい、まあ終わらない(笑)。 とりあえず1枚目をできるだけ早くダウンロードして表示できるように準備したい。 それと並行してダウンロードすると進捗の体感が悪い(これは職業病に近い、実際の仕事で処理経過表示に頭を抱えた経験あり)ので順序よくダウンロードする制御を入れる。 となるとタグに書かないでJSONに書きたい。 一つのページに複数のアルバムを表示できるように作りたいからアルバムの表示位置とJSONを対応させたい。 そこでアルバムの表示位置のタグのエントリにJSON文字列を丸ごと入れて画像のダウンロードに合わせて動的に画像のタグを追加していくことにしました。

<!-- head -->
<link rel="stylesheet" href="/photoswipe/photoswipe.css" />
<link rel="stylesheet" href="/photoswipe/style.css" />

<!-- 記事 -->

<!-- 処理経過 -->
<div id="progress"></div>
<div id="loading-overlay">
<div class="spinner"></div>
<div id="loading-count">読み込み中...</div>
</div>

<!-- アルバム表示位置 -->
<div id="album1" class="gallery album"
data-images='[
{ "href" : "oni01.jpg", "thumb" : "oni01_thumb.jpg" },
{ "href" : "oni02.jpg", "thumb" : "oni02_thumb.jpg" },
{ "href" : "oni03.jpg", "thumb" : "oni03_thumb.jpg" }
]'>
</div>

<!-- 記事末尾 -->
<small>
このアルバム表示はPhotoSwipeを利用しています。<a href="https://photoswipe.com/">PhotoSwipe: Responsive JavaScript Image Gallery</a>
</small>
<script type="module">
	import initGallery from '/photoswipe/loader.js';
	initGallery("album");
</script>

HTMLを簡素にした代わりにPhotoSwipeを呼ぶ準備が増え、それらの処理をjsファイルにまとめローダーとします。

import PhotoSwipeLightbox from './photoswipe-lightbox.esm.js';
import PhotoSwipe from './photoswipe.esm.js';

async function loadRealImage(linkInfo, container, map) {
	return new Promise(resolve => {
		const img = new Image();
		img.decoding = "async";
		img.src = linkInfo.href;
		img.onload = () => {
			map.set(linkInfo.href, img);
			resolve();
		};
		img.onerror = () => {
			console.warn("画像読み込み失敗:", linkInfo.href);
			map.set(linkInfo.href, null);
			resolve();
		};
	});
}

export default async function initGallery(className) {
	const galleries = document.querySelectorAll(`.${className}`);
	const overlay = document.getElementById('loading-overlay');
	const counter = document.getElementById('loading-count');
	const progress = document.getElementById('progress');

	// サムネイル作成
	let total = 0; // 総枚数
	for (const container of galleries) {
		const data = container.getAttribute("data-images");
		if (!data) continue;

		let images;
		try {
			images = JSON.parse(data);
			total += images.length;
		} catch {
			console.warn("不正な画像リストのためスキップ");
			continue;
		}

		for (const imgInfo of images) {
			const a = document.createElement('a');
			a.href = imgInfo.href;
			a.classList.add('pending');
			const thumb = document.createElement('img');
			thumb.src = imgInfo.thumb;
			thumb.loading = "lazy";
			thumb.decoding = "async";
			a.appendChild(thumb);
			container.appendChild(a);
		}
	}

	let lightboxInitialized = false;

	// 本物画像を順番にダウンロードする
	let loaded = 0; // 進捗表示
	for (const container of galleries) {
		const galleryId = container.id;
		if (!galleryId) continue;

		const data = container.getAttribute("data-images");
		if (!data) continue;

		let images;
		try {
			images = JSON.parse(data);
		} catch {
			continue;
		}

		const map = new Map();
		let lightbox = null;

		for (let i = 0; i < images.length; i++) {
			await loadRealImage(images[i], container, map);
			loaded++;

			if (progress) {
				if (loaded < total) {
					progress.textContent = `読み込み中... (${loaded}/${total})`;
				} else {
					progress.textContent = "読み込み完了";
				}
			}
			counter.textContent = "読み込み中";

			if (loaded === 1 && !lightboxInitialized) {
				overlay.style.display = 'none';
				lightbox = new PhotoSwipeLightbox({
					gallery: `#${galleryId}`,
					children: 'a',
					pswpModule: () => Promise.resolve(PhotoSwipe)
				});
				lightbox.init();
				lightboxInitialized = true;
			}

			// 本物画像ロード後にdata-pswp-width/heightを設定する
			const allLinks = container.querySelectorAll('a');
			const targetLink = allLinks[i];
			const img = map.get(images[i].href);
			if (targetLink && img) {
				targetLink.setAttribute('data-pswp-width', img.naturalWidth || 1600);
				targetLink.setAttribute('data-pswp-height', img.naturalHeight || 1200);
				targetLink.classList.remove('pending');
				targetLink.classList.add('completed');
			}
		}
	}
}

体裁に合わせたスタイルシートを用意します。

#loading-overlay {
	position: fixed;
	top: 0; left: 0; right: 0; bottom: 0;
	background: rgba(255, 255, 255, 0.9);
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	font-size: 18px;
	z-index: 9999;
}

.spinner {
	width: 40px;
	height: 40px;
	border: 4px solid #ccc;
	border-top-color: #333;
	border-radius: 50%;
	animation: spin 1s linear infinite;
	margin-bottom: 1em;
}

@keyframes spin {
	to { transform: rotate(360deg); }
}

.gallery {
	display: flex;
	flex-wrap: wrap;
	gap: 20px;
}

.gallery a img {
	width: 200px;
	height: auto;
	border-radius: 10px;
	box-shadow: 0 4px 10px rgba(0,0,0,0.15);
	cursor: zoom-in;
}

.gallery a.pending img {
	opacity: 0.7;
	filter: grayscale(100%);
}

.gallery a.completed img {
	opacity: 1.0;
	filter: none;
}

.pswp__button--arrow--prev,
.pswp__button--arrow--next {
	opacity: 1 !important;
	visibility: visible !important;
	transition: none !important;
}