From 7dc956cdab794e8f9ac81bee791dbeb74323e76b Mon Sep 17 00:00:00 2001 From: Valentin Silyutin Date: Sat, 10 Oct 2026 22:46:05 +0400 Subject: [PATCH] @advdominion/lightbox-gallery@5.1.0 --- packages/lightbox-gallery/index.js | 661 +++++++++++++------------ packages/lightbox-gallery/package.json | 2 +- packages/lightbox-gallery/styles.css | 20 +- 3 files changed, 368 insertions(+), 315 deletions(-) diff --git a/packages/lightbox-gallery/index.js b/packages/lightbox-gallery/index.js index 1755b56..f40408a 100644 --- a/packages/lightbox-gallery/index.js +++ b/packages/lightbox-gallery/index.js @@ -2,403 +2,440 @@ import { getScrollbarWidth } from '@advdominion/get-scrollbar-width'; -const getYTPlayer = () => { - const script = document.createElement('script'); - script.src = 'https://www.youtube.com/player_api'; - script.async = true; - document.body.append(script); - }, - getVKPlayer = () => { - const script = document.createElement('script'); - script.src = 'https://vk.com/js/api/videoplayer.js'; - script.async = true; - document.body.append(script); - }, - playVideo = (slide) => { - const youtube = slide.querySelector('.advdominion-lg__video_youtube'), - vk = slide.querySelector('.advdominion-lg__video_vk'), - rt = slide.querySelector('.advdominion-lg__video_rt'); +const ESCAPE_MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; - if (youtube) { - if (slide.YTPlayer) { - if (slide.classList.contains('swiper-slide-active')) { - slide.YTPlayer.playVideo(); - } - } else if (window.YT) { - if (GalleryYTPlayer.state) { - slide.YTPlayer = new YT.Player(youtube, { - videoId: youtube.dataset.id, - events: { - onReady(event) { - if (slide.classList.contains('swiper-slide-active')) { - event.target.playVideo(); - } - }, +/* + Экранирует HTML в значениях (сейчас — подписи), которые попадают в разметку через insertAdjacentHTML. + Без этого подпись с тегами выполнится как разметка. Готового санитайзера с нужной поддержкой браузеров нет. +*/ +const escapeHtml = (value) => String(value).replaceAll(/[&<>"']/g, (char) => ESCAPE_MAP[char]); + +const getYTPlayer = () => { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/player_api'; + script.async = true; + document.body.append(script); +}; + +const getVKPlayer = () => { + const script = document.createElement('script'); + script.src = 'https://vk.com/js/api/videoplayer.js'; + script.async = true; + document.body.append(script); +}; + +const playVideo = (slide) => { + const youtube = slide.querySelector('.advdominion-lg__video_youtube'); + const vk = slide.querySelector('.advdominion-lg__video_vk'); + const rt = slide.querySelector('.advdominion-lg__video_rt'); + + if (youtube) { + if (slide.YTPlayer) { + if (slide.classList.contains('swiper-slide-active')) { + slide.YTPlayer.playVideo(); + } + } else if (window.YT) { + if (GalleryYTPlayer.state) { + slide.YTPlayer = new YT.Player(youtube, { + videoId: youtube.dataset.id, + events: { + onReady(event) { + if (slide.classList.contains('swiper-slide-active')) { + event.target.playVideo(); + } }, - }); - } else { - GalleryYTPlayer.addListener(() => { - playVideo(slide); - }); - } + }, + }); } else { - getYTPlayer(); GalleryYTPlayer.addListener(() => { playVideo(slide); }); } - } else if (vk) { - if (slide.VKPlayer) { - if (slide.classList.contains('swiper-slide-active')) { - slide.VKPlayer.play(); - } - } else if (window.VK?.VideoPlayer) { - if (GalleryVKPlayer.state) { - slide.VKPlayer = VK.VideoPlayer(vk); - slide.VKPlayer.on('inited', () => { - if (slide.classList.contains('swiper-slide-active')) { - slide.VKPlayer.play(); - } - }); - } else { - GalleryVKPlayer.addListener(() => { - playVideo(slide); - }); - } + } else { + getYTPlayer(); + GalleryYTPlayer.addListener(() => { + playVideo(slide); + }); + } + } else if (vk) { + if (slide.VKPlayer) { + if (slide.classList.contains('swiper-slide-active')) { + slide.VKPlayer.play(); + } + } else if (window.VK?.VideoPlayer) { + if (GalleryVKPlayer.state) { + slide.VKPlayer = VK.VideoPlayer(vk); // eslint-disable-line new-cap + slide.VKPlayer.on('inited', () => { + if (slide.classList.contains('swiper-slide-active')) { + slide.VKPlayer.play(); + } + }); } else { - getVKPlayer(); GalleryVKPlayer.addListener(() => { playVideo(slide); }); } - } else if (rt) { - const videoId = /embed\/(\w+)/.exec(rt.getAttribute('src'))?.[1], - checkInterval = setInterval(() => { - if (window.GalleryRTPlayer) { - if (window.GalleryRTPlayer.has(videoId)) { - clearInterval(checkInterval); - if (slide.classList.contains('swiper-slide-active')) { - rt.contentWindow.postMessage( - JSON.stringify({ - type: 'player:play', - data: {}, - }), - '*', - ); - } - } - } else { - clearInterval(checkInterval); + } else { + getVKPlayer(); + GalleryVKPlayer.addListener(() => { + playVideo(slide); + }); + } + } else if (rt) { + /* + RuTube не отдаёт объект плеера вроде YT.Player или VK.VideoPlayer — о готовности он сообщает + через postMessage, а id готовых видео копятся в window.GalleryRTPlayer (см. слушатель ниже). + Поэтому ждём появления своего id в этом наборе и только тогда отправляем команду. + */ + const videoId = /embed\/(\w+)/.exec(rt.getAttribute('src'))?.[1]; + const checkInterval = setInterval(() => { + if (window.GalleryRTPlayer) { + if (window.GalleryRTPlayer.has(videoId)) { + clearInterval(checkInterval); + if (slide.classList.contains('swiper-slide-active')) { + rt.contentWindow.postMessage( + JSON.stringify({ + type: 'player:play', + data: {}, + }), + '*', + ); } - }, 100); - slide.checkInterval = checkInterval; - } - }, - pauseVideo = (slide) => { - const youtube = slide.querySelector('.advdominion-lg__video_youtube'), - vk = slide.querySelector('.advdominion-lg__video_vk'), - rt = slide.querySelector('.advdominion-lg__video_rt'); - - if (youtube && slide.YTPlayer) { - slide.YTPlayer.pauseVideo(); - } else if (vk && slide.VKPlayer) { - slide.VKPlayer.pause(); - } else if (rt) { - rt.contentWindow.postMessage( - JSON.stringify({ - type: 'player:pause', - data: {}, - }), - '*', - ); - } - }, - setVideoSize = (gallery) => { - const items = gallery.querySelectorAll('.advdominion-lg__video-wrapper'); - items[0].style.cssText = ''; - let height = (items[0].getBoundingClientRect().width * 9) / 16, - width = ''; - if (height > items[0].getBoundingClientRect().height) { - height = items[0].getBoundingClientRect().height; - width = `${(height * 16) / 9}px`; - } - for (const item of items) { - item.style.cssText = ` - height: ${height}px; - width: ${width}; - `; - } - }, - hide = async (duration = 500, easing = 'linear') => { - const gallery = document.body.querySelector('.advdominion-lg'); - - await gallery.animate([{ opacity: 1 }, { opacity: 0 }], { - duration, - easing, - }).finished; - - // Очищаем интервалы проверки для видео - for (const slide of gallery.querySelectorAll('.advdominion-lg__item_video')) { - if (slide.checkInterval) { - clearInterval(slide.checkInterval); + } + } else { + clearInterval(checkInterval); } + }, 100); + slide.checkInterval = checkInterval; + } +}; + +const pauseVideo = (slide) => { + const youtube = slide.querySelector('.advdominion-lg__video_youtube'); + const vk = slide.querySelector('.advdominion-lg__video_vk'); + const rt = slide.querySelector('.advdominion-lg__video_rt'); + + if (youtube && slide.YTPlayer) { + slide.YTPlayer.pauseVideo(); + } else if (vk && slide.VKPlayer) { + slide.VKPlayer.pause(); + } else if (rt) { + rt.contentWindow.postMessage( + JSON.stringify({ + type: 'player:pause', + data: {}, + }), + '*', + ); + } +}; + +const hide = async (duration = 500, easing = 'linear') => { + const gallery = document.body.querySelector('.advdominion-lg'); + + await gallery.animate([{ opacity: 1 }, { opacity: 0 }], { + duration, + easing, + }).finished; + + // Очищаем интервалы проверки для видео + for (const slide of gallery.querySelectorAll('.advdominion-lg__item_video')) { + if (slide.checkInterval) { + clearInterval(slide.checkInterval); } + } - gallery.remove(); - delete window.GalleryRTPlayer; + gallery.remove(); + // Сбрасываем накопленные id готовых RuTube-плееров, чтобы следующее открытие начинало с чистого состояния. + delete window.GalleryRTPlayer; - document.body.style.cssText = ` - overflow: ''; - margin-right: ''; - `; - }, - show = async (duration = 500, easing = 'linear') => { - const gallery = document.body.querySelector('.advdominion-lg'); + document.body.style.overflow = ''; + document.body.style.marginRight = ''; +}; - document.body.style.cssText = ` - overflow: hidden; - margin-right: ${getScrollbarWidth()}px - `; +const show = async (duration = 500, easing = 'linear') => { + const gallery = document.body.querySelector('.advdominion-lg'); - await gallery.animate([{ opacity: 0 }, { opacity: 1 }], { - duration, - easing, - }).finished; - }, - init = async (items = [], options = {}, index = 0) => { - const [{ default: Swiper }, { Navigation, Pagination }] = await Promise.all([ - import('swiper'), - import('swiper/modules'), - ]), - isVideoInGallery = { - yt: false, - vk: false, - rt: false, - }; - items = items.map(({ url: item, caption }) => { - if (/youtube\.com\/watch\?v=/.test(item)) { - isVideoInGallery.yt = true; - return ` + document.body.style.overflow = 'hidden'; + document.body.style.marginRight = `${getScrollbarWidth()}px`; + + await gallery.animate([{ opacity: 0 }, { opacity: 1 }], { + duration, + easing, + }).finished; +}; + +const init = async (items = [], options = {}, index = 0) => { + // Вторая галерея поверх первой сломала бы закрытие: show/hide ищут только первый элемент .advdominion-lg. + if (document.body.querySelector('.advdominion-lg')) { + return; + } + // Один отменяемый контроллер на время жизни галереи: при закрытии снимает все её слушатели. + const controller = new AbortController(); + const [{ default: Swiper }, { Navigation, Pagination }] = await Promise.all([ + import('swiper'), + import('swiper/modules'), + ]); + const isVideoInGallery = { + yt: false, + vk: false, + rt: false, + }; + const slides = items.map(({ url: item, caption }) => { + if (/youtube\.com\/watch\?v=/.test(item)) { + isVideoInGallery.yt = true; + return `
-
-
- Загрузка YouTube-плеера... -
-
- ${caption ? `
${caption}
` : ''} -
- `; - } else if (/(?:vkvideo\.ru|vk\.com)\/video/.test(item)) { - isVideoInGallery.vk = true; - const videoId = /(?:vkvideo\.ru|vk\.com)\/video(-?\d+)_(\d+)/.exec(item); - if (videoId === null) { - return ` -
+
-
- Ошибка при загрузке VK-плеера +
+ Загрузка YouTube-плеера...
- ${caption ? `
${caption}
` : ''}
- `; - } - return ` -
-
- -
- ${caption ? `
${caption}
` : ''} + ${caption ? `
${escapeHtml(caption)}
` : ''}
`; - } else if (/rutube\.ru/.test(item)) { - isVideoInGallery.rt = true; - const videoId = /video\/(\w+)/.exec(item); - if (videoId === null) { - return ` + } else if (/(?:vkvideo\.ru|vk\.com)\/video/.test(item)) { + isVideoInGallery.vk = true; + const videoId = /(?:vkvideo\.ru|vk\.com)\/video(-?\d+)_(\d+)/.exec(item); + if (videoId === null) { + return `
-
-
- Ошибка при загрузке RuTube-плеера +
+
+
+ Ошибка при загрузке VK-плеера +
- ${caption ? `
${caption}
` : ''} + ${caption ? `
${escapeHtml(caption)}
` : ''}
`; - } - return ` -
-
- -
- ${caption ? `
${caption}
` : ''} -
- `; } return ` +
+
+
+ +
+
+ ${caption ? `
${escapeHtml(caption)}
` : ''} +
+ `; + } else if (/rutube\.ru/.test(item)) { + isVideoInGallery.rt = true; + const videoId = /video\/(\w+)/.exec(item); + if (videoId === null) { + return ` +
+
+
+
+ Ошибка при загрузке RuTube-плеера +
+
+
+ ${caption ? `
${escapeHtml(caption)}
` : ''} +
+ `; + } + return ` +
+
+
+ +
+
+ ${caption ? `
${escapeHtml(caption)}
` : ''} +
+ `; + } + return `
- ${caption ? `
${caption}
` : ''} + ${caption ? `
${escapeHtml(caption)}
` : ''}
`; - }); + }); - if (isVideoInGallery.yt && !window.GalleryYTPlayer) { - window.GalleryYTPlayer = { - state: false, - get init() { - return this.state; - }, - set init(val) { - this.state = val; - for (const i of this.callback) { - i(val); - } - }, - callback: [], - addListener(callback) { - this.callback.push(callback); - }, - }; + /* + GalleryYTPlayer и GalleryVKPlayer — простые накопители колбэков до готовности внешнего API. + Пока плеер не загружен, playVideo регистрирует себя через addListener и будет вызван снова, + когда init станет true. + */ + if (isVideoInGallery.yt && !window.GalleryYTPlayer) { + window.GalleryYTPlayer = { + state: false, + get init() { + return this.state; + }, + set init(val) { + this.state = val; + for (const i of this.callback) { + i(val); + } + }, + callback: [], + addListener(callback) { + this.callback.push(callback); + }, + }; + /* + YouTube вызывает onYouTubeIframeAPIReady только один раз, поэтому нельзя просто перезаписать его: + если API уже загружен или колбэк занят другим кодом, он больше не сработает. Поэтому сохраняем + прежний обработчик и явно отмечаем готовность, когда API уже присутствует. + */ + if (window.YT?.Player) { + window.GalleryYTPlayer.init = true; + } else { + const prevOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = () => { + prevOnYouTubeIframeAPIReady?.(); window.GalleryYTPlayer.init = true; }; } + } - if (isVideoInGallery.vk && !window.GalleryVKPlayer) { - window.GalleryVKPlayer = { - state: false, - get init() { - return this.state; - }, - set init(val) { - this.state = val; - for (const i of this.callback) { - i(val); - } - }, - callback: [], - addListener(callback) { - this.callback.push(callback); - }, - }; - - const checkInterval = setInterval(() => { - if (window.VK?.VideoPlayer) { - clearInterval(checkInterval); - window.GalleryVKPlayer.init = true; + if (isVideoInGallery.vk && !window.GalleryVKPlayer) { + window.GalleryVKPlayer = { + state: false, + get init() { + return this.state; + }, + set init(val) { + this.state = val; + for (const i of this.callback) { + i(val); } - }, 100); - } + }, + callback: [], + addListener(callback) { + this.callback.push(callback); + }, + }; - if (isVideoInGallery.rt) { - window.GalleryRTPlayer = new Set(); - window.addEventListener('message', (event) => { + // У VK нет события готовности API, поэтому проверяем его появление опросом. + const checkInterval = setInterval(() => { + if (window.VK?.VideoPlayer) { + clearInterval(checkInterval); + window.GalleryVKPlayer.init = true; + } + }, 100); + } + + if (isVideoInGallery.rt) { + window.GalleryRTPlayer = new Set(); + window.addEventListener( + 'message', + (event) => { if (event.origin.includes('rutube.ru') && typeof event.data === 'string') { const message = JSON.parse(event.data); if (message.type === 'player:ready') { window.GalleryRTPlayer.add(message.data.videoId); } } - }); - } + }, + { signal: controller.signal }, + ); + } - document.body.insertAdjacentHTML( - 'beforeend', - ` + document.body.insertAdjacentHTML( + 'beforeend', + `
- ${items.join('')} + ${slides.join('')}
${options.pagination || ''} ${options.navigation || ''}
`, - ); + ); - const gallery = document.body.querySelector('.advdominion-lg'); + const gallery = document.body.querySelector('.advdominion-lg'); - gallery.querySelector('.advdominion-lg__container').style.cssText = ` + gallery.querySelector('.advdominion-lg__container').style.cssText = ` visibility: hidden; `; - await show(options?.animation?.show?.duration, options?.animation?.show?.easing); + await show(options?.animation?.show?.duration, options?.animation?.show?.easing); - const swiper = new Swiper(gallery.querySelector('.advdominion-lg__container'), { - init: false, - initialSlide: index, - ...options.swiper, - modules: [...new Set([Navigation, Pagination, ...(options.swiper?.modules || [])])], - }); + const swiper = new Swiper(gallery.querySelector('.advdominion-lg__container'), { + init: false, + initialSlide: index, + ...options.swiper, + modules: [...new Set([Navigation, Pagination, ...(options.swiper?.modules || [])])], + }); - swiper.on('init', function init() { - gallery.querySelector('.advdominion-lg__container').style.cssText = ` + swiper.on('init', function init() { + gallery.querySelector('.advdominion-lg__container').style.cssText = ` visibility: ''; `; - if (this.slides[this.activeIndex].classList.contains('advdominion-lg__item_video')) { - playVideo(this.slides[this.activeIndex]); - } + if (this.slides[this.activeIndex].classList.contains('advdominion-lg__item_video')) { + playVideo(this.slides[this.activeIndex]); + } + }); - if (isVideoInGallery.yt || isVideoInGallery.vk || isVideoInGallery.rt) { - setVideoSize(gallery); - } - }); - - swiper.on('slideChange', function init() { - setTimeout(() => { - for (const [index, slide] of Object.entries(this.slides)) { - if (slide.classList.contains('advdominion-lg__item_video')) { - if (Number(index) === this.activeIndex) { - playVideo(slide); - } else { - pauseVideo(slide); - } + swiper.on('slideChange', function init() { + setTimeout(() => { + for (const [index, slide] of Object.entries(this.slides)) { + if (slide.classList.contains('advdominion-lg__item_video')) { + if (Number(index) === this.activeIndex) { + playVideo(slide); + } else { + pauseVideo(slide); } } - }, 0); - }); - - swiper.on('resize', () => { - if (isVideoInGallery.yt || isVideoInGallery.vk || isVideoInGallery.rt) { - setVideoSize(gallery); } - }); + }, 0); + }); - swiper.init(); + swiper.init(); - const closeHandler = () => { - hide(options?.animation?.hide?.duration, options?.animation?.hide?.easing); - }, - keydownHandler = (e) => { - if (e.key === 'Escape') { - closeHandler(); - document.body.removeEventListener('keydown', keydownHandler); - } - }; - - gallery.querySelector('.advdominion-lg__close').addEventListener('click', closeHandler); - document.body.addEventListener('keydown', keydownHandler); + const closeHandler = () => { + hide(options?.animation?.hide?.duration, options?.animation?.hide?.easing); + controller.abort(); }; + const keydownHandler = (e) => { + if (e.key === 'Escape') { + closeHandler(); + } + }; + + gallery.querySelector('.advdominion-lg__close').addEventListener('click', closeHandler); + document.body.addEventListener('keydown', keydownHandler, { signal: controller.signal }); +}; export const gallery = (container, options) => { const items = []; + /* + Подпись берётся из элемента, на который указывает data-gallery-caption у ссылки, + поэтому одна и та же подпись не дублируется в разметке галереи. + */ if (options.single) { const element = container.children[0]; items.push({ diff --git a/packages/lightbox-gallery/package.json b/packages/lightbox-gallery/package.json index 9063d62..13688a6 100644 --- a/packages/lightbox-gallery/package.json +++ b/packages/lightbox-gallery/package.json @@ -1,6 +1,6 @@ { "name": "@advdominion/lightbox-gallery", - "version": "5.0.1", + "version": "5.1.0", "type": "module", "main": "index.js", "repository": { diff --git a/packages/lightbox-gallery/styles.css b/packages/lightbox-gallery/styles.css index 197bac7..6b0c902 100644 --- a/packages/lightbox-gallery/styles.css +++ b/packages/lightbox-gallery/styles.css @@ -39,15 +39,31 @@ .advdominion-lg__item_video.swiper-slide { } +/* + Обёртка вокруг видео внутри слайда. Слайд — это колонка из видео и подписи, поэтому обёртка + забирает всю высоту, оставшуюся после подписи и отступа, и центрирует в себе видео. + container-type: size нужен, чтобы cqw/cqh в .advdominion-lg__video-wrapper считались от этой + области, а не от всего слайда: иначе 16:9-блок вырастет на высоту подписи и наедет на неё. +*/ +.advdominion-lg__video-area { + align-items: center; + container-type: size; + display: flex; + flex-grow: 1; + justify-content: center; + min-height: 0; + width: 100%; +} + .advdominion-lg__image { max-height: 100%; max-width: 100%; } .advdominion-lg__video-wrapper { - height: 100%; + aspect-ratio: 16 / 9; position: relative; - width: 100%; + width: min(100cqw, calc(100cqh * 16 / 9)); } .advdominion-lg__video {