feat: upgrade to PhotoSwipe v5 (#1233)

* refactor: migrate external resource definitions from YAML to TOML format

* feat: upgrade to PhotoSwipe v5
This commit is contained in:
Jimmy
2026-01-25 23:13:34 +01:00
committed by GitHub
parent 9d9ecc4d00
commit b1ddad1d22
5 changed files with 126 additions and 278 deletions
+74 -163
View File
@@ -1,186 +1,97 @@
declare global { const wrap = (figures: HTMLElement[]) => {
interface Window { const galleryContainer = document.createElement('div');
PhotoSwipe: any; galleryContainer.className = 'gallery';
PhotoSwipeUI_Default: any
const parentNode = figures[0].parentNode!,
first = figures[0];
parentNode.insertBefore(galleryContainer, first)
for (const figure of figures) {
galleryContainer.appendChild(figure);
} }
} }
interface PhotoSwipeItem { export default (container: HTMLElement) => {
w: number; /// The process of wrapping image with figure tag is done using JavaScript instead of only Hugo markdown render hook
h: number; /// because it can not detect whether image is being wrapped by a link or not
src: string; /// and it lead to a invalid HTML construction (<a><figure><img></figure></a>)
msrc: string; const images = container.querySelectorAll('img.gallery-image') as NodeListOf<HTMLImageElement>;
title?: string; for (const img of Array.from(images)) {
el: HTMLElement; /// Images are wrapped with figure tag if the paragraph has only images without texts
} /// This is done to allow inline images within paragraphs
const paragraph = img.closest('p');
class StackGallery { if (!paragraph || !container.contains(paragraph)) continue;
private galleryUID: number;
private items: PhotoSwipeItem[] = [];
constructor(container: HTMLElement, galleryUID = 1) { if (paragraph.textContent.trim() == '') {
if (window.PhotoSwipe == undefined || window.PhotoSwipeUI_Default == undefined) { /// Once we insert figcaption, this check no longer works
console.error("PhotoSwipe lib not loaded."); /// So we add a class to paragraph to mark it
return; paragraph.classList.add('no-text');
} }
this.galleryUID = galleryUID; let isNewLineImage = paragraph.classList.contains('no-text');
if (!isNewLineImage) continue;
StackGallery.createGallery(container); const hasLink = img.parentElement!.tagName == 'A';
this.loadItems(container);
this.bindClick();
}
private loadItems(container: HTMLElement) { let el: HTMLElement = img;
this.items = []; /// Wrap image with figure tag, with flex-grow and flex-basis values extracted from img's data attributes
const figure = document.createElement('figure');
figure.classList.add('gallery-image');
figure.style.setProperty('flex-grow', img.getAttribute('data-flex-grow') || '1');
figure.style.setProperty('flex-basis', img.getAttribute('data-flex-basis') || '0');
const figures = container.querySelectorAll('figure.gallery-image'); if (hasLink) {
/// Wrap <a> if it exists
el = img.parentElement!;
el.classList.add('image-link');
el.setAttribute('data-pswp-width', img.getAttribute('width')!);
el.setAttribute('data-pswp-height', img.getAttribute('height')!);
} else {
const a = document.createElement('a');
a.href = img.src;
a.setAttribute('class', 'image-link');
a.setAttribute('target', '_blank');
a.setAttribute('data-pswp-width', img.getAttribute('width')!);
a.setAttribute('data-pswp-height', img.getAttribute('height')!);
img.parentNode!.insertBefore(a, img);
a.appendChild(img);
el = a;
}
for (const el of figures) { el.parentElement!.insertBefore(figure, el);
const figcaption = el.querySelector('figcaption'), figure.appendChild(el);
img = el.querySelector('img');
let aux: PhotoSwipeItem = { /// Add figcaption if it exists
w: parseInt(img.getAttribute('width')), if (img.hasAttribute('alt')) {
h: parseInt(img.getAttribute('height')), const figcaption = document.createElement('figcaption');
src: img.src, figcaption.innerText = img.getAttribute('alt')!;
msrc: img.getAttribute('data-thumb') || img.src, figure.appendChild(figcaption);
el: el
}
if (figcaption) {
aux.title = figcaption.innerHTML;
}
this.items.push(aux);
} }
} }
public static createGallery(container: HTMLElement) { const figuresEl = container.querySelectorAll('figure.gallery-image') as NodeListOf<HTMLElement>;
/// The process of wrapping image with figure tag is done using JavaScript instead of only Hugo markdown render hook
/// because it can not detect whether image is being wrapped by a link or not
/// and it lead to a invalid HTML construction (<a><figure><img></figure></a>)
const images = container.querySelectorAll('img.gallery-image'); let currentGallery: HTMLElement[] = [];
for (const img of Array.from(images)) {
/// Images are wrapped with figure tag if the paragraph has only images without texts
/// This is done to allow inline images within paragraphs
const paragraph = img.closest('p');
if (!paragraph || !container.contains(paragraph)) continue; for (const figure of Array.from(figuresEl)) {
if (!currentGallery.length) {
if (paragraph.textContent.trim() == '') { /// First iteration
/// Once we insert figcaption, this check no longer works currentGallery = [figure];
/// So we add a class to paragraph to mark it
paragraph.classList.add('no-text');
}
let isNewLineImage = paragraph.classList.contains('no-text');
if (!isNewLineImage) continue;
const hasLink = img.parentElement.tagName == 'A';
let el: HTMLElement = img;
/// Wrap image with figure tag, with flex-grow and flex-basis values extracted from img's data attributes
const figure = document.createElement('figure');
figure.style.setProperty('flex-grow', img.getAttribute('data-flex-grow') || '1');
figure.style.setProperty('flex-basis', img.getAttribute('data-flex-basis') || '0');
if (hasLink) {
/// Wrap <a> if it exists
el = img.parentElement;
}
el.parentElement.insertBefore(figure, el);
figure.appendChild(el);
/// Add figcaption if it exists
if (img.hasAttribute('alt')) {
const figcaption = document.createElement('figcaption');
figcaption.innerText = img.getAttribute('alt');
figure.appendChild(figcaption);
}
/// Wrap img tag with <a> tag if image was not wrapped by <a> tag
if (!hasLink) {
figure.className = 'gallery-image';
const a = document.createElement('a');
a.href = img.src;
a.setAttribute('target', '_blank');
img.parentNode.insertBefore(a, img);
a.appendChild(img);
}
} }
else if (figure.previousElementSibling === currentGallery[currentGallery.length - 1]) {
const figuresEl = container.querySelectorAll('figure.gallery-image'); /// Adjacent figures
currentGallery.push(figure);
let currentGallery = [];
for (const figure of figuresEl) {
if (!currentGallery.length) {
/// First iteration
currentGallery = [figure];
}
else if (figure.previousElementSibling === currentGallery[currentGallery.length - 1]) {
/// Adjacent figures
currentGallery.push(figure);
}
else if (currentGallery.length) {
/// End gallery
StackGallery.wrap(currentGallery);
currentGallery = [figure];
}
} }
else if (currentGallery.length) {
if (currentGallery.length > 0) { /// End gallery
StackGallery.wrap(currentGallery); wrap(currentGallery);
currentGallery = [figure];
} }
} }
/** if (currentGallery.length > 0) {
* Wrap adjacent figure tags with div.gallery wrap(currentGallery);
* @param figures
*/
public static wrap(figures: HTMLElement[]) {
const galleryContainer = document.createElement('div');
galleryContainer.className = 'gallery';
const parentNode = figures[0].parentNode,
first = figures[0];
parentNode.insertBefore(galleryContainer, first)
for (const figure of figures) {
galleryContainer.appendChild(figure);
}
} }
};
public open(index: number) {
const pswp = document.querySelector('.pswp') as HTMLDivElement;
const ps = new window.PhotoSwipe(pswp, window.PhotoSwipeUI_Default, this.items, {
index: index,
galleryUID: this.galleryUID,
getThumbBoundsFn: (index) => {
const thumbnail = this.items[index].el.getElementsByTagName('img')[0],
pageYScroll = window.pageYOffset || document.documentElement.scrollTop,
rect = thumbnail.getBoundingClientRect();
return { x: rect.left, y: rect.top + pageYScroll, w: rect.width };
}
});
ps.init();
}
private bindClick() {
for (const [index, item] of this.items.entries()) {
const a = item.el.querySelector('a');
a.addEventListener('click', (e) => {
e.preventDefault();
this.open(index);
})
}
}
}
export default StackGallery;
+7 -9
View File
@@ -5,13 +5,12 @@
* @website: https://jimmycai.com * @website: https://jimmycai.com
* @link: https://github.com/CaiJimmy/hugo-theme-stack * @link: https://github.com/CaiJimmy/hugo-theme-stack
*/ */
import StackGallery from "ts/gallery"; import { getColor } from './color';
import { getColor } from 'ts/color'; import menu from './menu';
import menu from 'ts/menu'; import createElement from './createElement';
import createElement from 'ts/createElement'; import StackColorScheme from './colorScheme';
import StackColorScheme from 'ts/colorScheme'; import { setupScrollspy } from './scrollspy';
import { setupScrollspy } from 'ts/scrollspy'; import { setupSmoothAnchors } from './smoothAnchors';
import { setupSmoothAnchors } from "ts/smoothAnchors";
let Stack = { let Stack = {
init: () => { init: () => {
@@ -22,7 +21,6 @@ let Stack = {
const articleContent = document.querySelector('.article-content') as HTMLElement; const articleContent = document.querySelector('.article-content') as HTMLElement;
if (articleContent) { if (articleContent) {
new StackGallery(articleContent);
setupSmoothAnchors(); setupSmoothAnchors();
setupScrollspy(); setupScrollspy();
} }
@@ -91,7 +89,7 @@ let Stack = {
}); });
}); });
new StackColorScheme(document.getElementById('dark-mode-toggle')); new StackColorScheme(document.getElementById('dark-mode-toggle')!);
} }
} }
+19
View File
@@ -0,0 +1,19 @@
Vibrant = [
{ src = "https://cdn.jsdelivr.net/npm/node-vibrant@3.1.6/dist/vibrant.min.js", integrity = "sha256-awcR2jno4kI5X0zL8ex0vi2z+KMkF24hUW8WePSA9HM=", type = "script" },
]
KaTeX = [
{ src = "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css", integrity = "sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV", type = "style" },
{ src = "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js", integrity = "sha384-XjKyOOlGwcjNTAIQHIpgOno0Hl1YQqzUOEleOLALmuqehneUG+vnGctmUb0ZY0l8", type = "script", defer = true },
{ src = "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js", integrity = "sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05", type = "script", defer = true },
]
Cactus = [
{ src = "https://latest.cactus.chat/cactus.js", type = "script" },
{ src = "https://latest.cactus.chat/style.css", type = "style" },
]
[PhotoSwipe]
Style = "https://cdn.jsdelivr.net/npm/photoswipe@5.4.4/dist/photoswipe.css"
Core = "https://cdn.jsdelivr.net/npm/photoswipe@5.4.4/dist/photoswipe.esm.min.js"
Lightbox = "https://cdn.jsdelivr.net/npm/photoswipe@5.4.4/dist/photoswipe-lightbox.esm.min.js"
-44
View File
@@ -1,44 +0,0 @@
Vibrant:
- src: https://cdn.jsdelivr.net/npm/node-vibrant@3.1.6/dist/vibrant.min.js
integrity: sha256-awcR2jno4kI5X0zL8ex0vi2z+KMkF24hUW8WePSA9HM=
type: script
PhotoSwipe:
- src: https://cdn.jsdelivr.net/npm/photoswipe@4.1.3/dist/photoswipe.min.js
integrity: sha256-ePwmChbbvXbsO02lbM3HoHbSHTHFAeChekF1xKJdleo=
type: script
defer: true
- src: https://cdn.jsdelivr.net/npm/photoswipe@4.1.3/dist/photoswipe-ui-default.min.js
integrity: sha256-UKkzOn/w1mBxRmLLGrSeyB4e1xbrp4xylgAWb3M42pU=
type: script
defer: true
- src: https://cdn.jsdelivr.net/npm/photoswipe@4.1.3/dist/default-skin/default-skin.min.css
type: style
- src: https://cdn.jsdelivr.net/npm/photoswipe@4.1.3/dist/photoswipe.min.css
type: style
KaTeX:
- src: https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css
integrity: sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV
type: style
- src: https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js
integrity: sha384-XjKyOOlGwcjNTAIQHIpgOno0Hl1YQqzUOEleOLALmuqehneUG+vnGctmUb0ZY0l8
type: script
defer: true
- src: https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js
integrity: sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05
type: script
defer: true
Cactus:
- src: https://latest.cactus.chat/cactus.js
integrity:
type: script
- src: https://latest.cactus.chat/style.css
integrity:
type: style
@@ -1,68 +1,32 @@
<!-- Root element of PhotoSwipe. Must have class pswp. --> {{- $opts := dict "minify" hugo.IsProduction "format" "esm" -}}
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true"> {{- $galleryScript := resources.Get "ts/gallery.ts" | js.Build $opts -}}
<!-- Background of PhotoSwipe. {{ $style := .Site.Data.external.PhotoSwipe.Style }}
It's a separate element as animating opacity is faster than rgba(). --> {{ $core := .Site.Data.external.PhotoSwipe.Core }}
<div class="pswp__bg"></div> {{ $lightbox := .Site.Data.external.PhotoSwipe.Lightbox }}
<!-- Slides wrapper with overflow:hidden. --> <script type="module">
<div class="pswp__scroll-wrap"> import gallery from '{{ $galleryScript.RelPermalink }}';
<!-- Container that holds slides. const articleContent = document.querySelector('.article-content');
PhotoSwipe keeps only 3 of them in the DOM to save memory. const shouldLoad = articleContent && (articleContent.querySelectorAll('figure').length > 0 || articleContent.querySelectorAll('img.gallery-image').length > 0);
Don't modify these 3 pswp__item elements, data is added later on. -->
<div class="pswp__container"> if (shouldLoad) {
<div class="pswp__item"></div> gallery(articleContent);
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<!-- Default (PhotoSwipeUI_Default) interface on top of sliding area. Can be changed. --> const PhotoSwipeLightbox = (await import("{{ $lightbox | safeJS }}")).default;
<div class="pswp__ui pswp__ui--hidden"> const styleHref = "{{ $style | safeJS }}";
<div class="pswp__top-bar"> const styleTag = document.createElement('link');
styleTag.rel = 'stylesheet';
styleTag.href = styleHref;
document.head.appendChild(styleTag);
<!-- Controls are self-explanatory. Order can be changed. --> const lightbox = new PhotoSwipeLightbox({
gallerySelector: '.article-content',
<div class="pswp__counter"></div> childSelector: 'figure a.image-link',
pswpModule: () => import("{{ $core | safeJS }}")
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button> });
lightbox.init();
<button class="pswp__button pswp__button--share" title="Share"></button> }
</script>
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
<!-- Preloader demo https://codepen.io/dimsemenov/pen/yyBWoR -->
<!-- element will get class pswp__preloader--active when preloader is running -->
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut"></div>
</div>
</div>
</div>
</div>
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div class="pswp__share-tooltip"></div>
</div>
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
</button>
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
</button>
<div class="pswp__caption">
<div class="pswp__caption__center"></div>
</div>
</div>
</div>
</div>
{{- partial "helper/external" (dict "Context" . "Namespace" "PhotoSwipe") -}}