diff --git a/hlx_statics/blocks/carousel/carousel.css b/hlx_statics/blocks/carousel/carousel.css index 8df7013ce..15b49f218 100644 --- a/hlx_statics/blocks/carousel/carousel.css +++ b/hlx_statics/blocks/carousel/carousel.css @@ -70,6 +70,8 @@ main div.carousel-wrapper div.carousel p.image-container picture { main div.carousel-wrapper div.carousel { max-width: 1280px; margin: auto; + display: flex; + flex-direction: column; } main div.carousel-wrapper div.carousel .block-container { @@ -99,6 +101,52 @@ main div.carousel-wrapper div.carousel .media-container { padding: 0 8px; } +main div.carousel-wrapper div.carousel .video-element { + width: 100%; + max-width: 100%; +} + +main div.carousel-wrapper div.carousel .video-element.embed-youtube iframe { + width: 100%; + max-width: 100%; + aspect-ratio: 16 / 9; + height: auto; + border: 0; + display: block; + position: relative; +} + +main div.carousel-wrapper div.carousel .video-element .carousel-video-embed, +main div.carousel-wrapper div.carousel .video-element > div { + width: 100% !important; + height: 0 !important; + position: relative !important; + padding-bottom: 56.25% !important; +} + +main div.carousel-wrapper div.carousel .video-element .carousel-video-embed--mp4 { + height: auto !important; + padding-bottom: 0 !important; +} + +main div.carousel-wrapper div.carousel .video-element .carousel-video-embed--mp4 video { + position: relative; + width: 100%; + height: auto; + display: block; +} + +main div.carousel-wrapper div.carousel .video-element:not(.embed-youtube) iframe, +main div.carousel-wrapper div.carousel .video-element:not(.embed-youtube) > div iframe, +main div.carousel-wrapper div.carousel .video-element > div video:not(.carousel-video-embed--mp4 video) { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + main div.carousel-wrapper div.carousel .text-container { display: flex; flex-direction: column; diff --git a/hlx_statics/blocks/carousel/carousel.js b/hlx_statics/blocks/carousel/carousel.js index 0e6916792..0481a712b 100644 --- a/hlx_statics/blocks/carousel/carousel.js +++ b/hlx_statics/blocks/carousel/carousel.js @@ -2,6 +2,99 @@ import { createTag, removeEmptyPTags, } from "../../scripts/lib-adobeio.js"; +import { + isVideoAnchor, + isVideoUrl, + mountCarouselVideo, + resolveEmbedBlockVideo, + resolveVideoUrl, +} from '../../components/video-embed-utils.js'; + +function getVideoLinkContainer(anchor) { + return anchor.closest('.embed.block') + || anchor.closest('p') + || anchor.parentElement; +} + +function processCarouselVideos(carouselBlock) { + carouselBlock.querySelectorAll('.carousel-container').forEach((slideCell) => { + if (slideCell.querySelector(':scope > .video-element')) return; + + slideCell.querySelectorAll('a').forEach((anchor) => { + if (!isVideoAnchor(anchor)) return; + const urlString = resolveVideoUrl(anchor); + if (!urlString) return; + mountCarouselVideo( + carouselBlock, + slideCell, + getVideoLinkContainer(anchor), + urlString, + anchor.textContent?.trim() || 'Video content', + ); + }); + + if (slideCell.querySelector(':scope > .video-element')) return; + + slideCell.querySelectorAll(':scope > .embed.block').forEach((embedBlock) => { + const resolved = resolveEmbedBlockVideo(embedBlock); + if (!resolved) return; + mountCarouselVideo( + carouselBlock, + slideCell, + embedBlock, + resolved.urlString, + resolved.title, + ); + }); + + if (slideCell.querySelector(':scope > .video-element')) return; + + slideCell.querySelectorAll(':scope > p').forEach((paragraph) => { + if (paragraph.closest('.video-element')) return; + if (paragraph.querySelector('a, picture, img, video, iframe')) return; + const text = paragraph.textContent.trim(); + const match = text.match(/^(https?:\/\/\S+)$/i); + if (!match || !isVideoUrl(match[1])) return; + mountCarouselVideo(carouselBlock, slideCell, paragraph, match[1], 'Video content'); + }); + }); +} + +function setupCarouselVideoMedia(carouselBlock) { + carouselBlock.querySelectorAll('.video-element').forEach((vid) => { + const slide = vid.closest('.carousel-container'); + if (!slide?.id) return; + vid.id = `media-flex-div-${slide.id}`; + vid.classList.add('media-container'); + }); +} + +/** + * Hyperlinked images inside embed blocks are wrapped in extra divs; unwrap them. + * Video embed links (no picture) are left for processCarouselVideos. + */ +function reformatHyperlinkImages(carouselBlock) { + carouselBlock.querySelectorAll('div.embed.block > div > div > a').forEach((anchor) => { + const picture = anchor.querySelector('picture'); + if (!picture) return; + + const wrapper = anchor.firstElementChild; + if (wrapper && wrapper !== picture && !wrapper.contains(picture)) { + anchor.append(picture); + anchor.removeChild(wrapper); + } + + const newDivParent = anchor.parentElement?.parentElement?.parentElement?.parentElement; + const replaceTarget = newDivParent?.firstElementChild; + if (!newDivParent || !replaceTarget) return; + + const paragraphWrapper = createTag('p', {}); + anchor.parentElement?.removeChild(anchor); + paragraphWrapper.append(anchor); + newDivParent.replaceChild(paragraphWrapper, replaceTarget); + }); +} + /** * decorates the carousel * @param {Element} block The carousel block element @@ -36,15 +129,6 @@ export default async function decorate(block) { //add a count to keep track of which slide is showing let count = 1; - //load the video link. - const a = block.querySelectorAll("a"); - const videoLinks = Array.from(a).filter(link => - link.title.includes('https') - ); - for (let i = 0; i < videoLinks.length; i++) { - loadVideoURL(block, videoLinks[i]); - } - block.querySelectorAll(':scope > div:not([class]) > div:not([class])').forEach((innerDiv, index) => { // one outer div for each slide - add class to inner div and remove outerDiv innerDiv.classList.add("carousel-container"); @@ -84,6 +168,9 @@ export default async function decorate(block) { carousel_block_child.insertBefore(arrow_button_previous, carousel_section); carousel_block_child.append(arrow_button_forward); + processCarouselVideos(block); + setupCarouselVideoMedia(block); + //add id to image and add image to left div block.querySelectorAll("img").forEach((img) => { // checks if dealing with a hyperlinked image @@ -243,33 +330,6 @@ export default async function decorate(block) { } } - /** - * For images that have hyperlinks attached to them in Google Docs, they are wrapped in an anchor tag, which messes up - * formatting the carousel slide. This function reformats the images/links so they are in a similar format to other slides - */ - function reformatHyperlinkImages(block) { - // Selects all hyper linked images - block.querySelectorAll("div.embed.block > div > div > a").forEach((a) => { - const picture = a.firstElementChild.firstElementChild; - if (picture) { - a.append(picture); - a.removeChild(a.firstElementChild); - } - const paragraphWrapper = createTag("p", {}); - // Because of link, image is surrounded by numerous divs. Navigates back up to OG parent - const newDivParent = - a.parentElement.parentElement.parentElement.parentElement; - a.parentElement.removeChild(a); - paragraphWrapper.append(a); - // pulls out the old image container and replaces it with pargraph-wrapped image - // Format is same as other slides - newDivParent.replaceChild( - paragraphWrapper, - newDivParent.firstElementChild - ); - }); - } - //change color of circle button when clicked const buttons = block.querySelectorAll(".carousel-circle"); buttons[0].classList.add("carousel-circle-selected"); //when reloading first slide should be selected @@ -289,63 +349,6 @@ export default async function decorate(block) { }); }); - - // load the video url and append to the video element. - function loadVideoURL(block, a) { - // block.className = "carousel"; - const link = a.href; - const url = new URL(link); - a.insertAdjacentHTML("afterend", loadUrl(url)); - const videoElement = createTag("div", { class: "video-element" }); - videoElement.innerHTML = a.parentElement.innerHTML; - a.parentElement.parentElement.append(videoElement); - - a.parentElement.remove(); - videoElement.querySelector("a").remove(); - videoElement.parentElement.classList.remove("button-container") - } - - function loadUrl(url) { - let html; - const embed = url.pathname; - // Check if the URL is a youtube link. - const usp = new URLSearchParams(url.search); - let vid = encodeURIComponent(usp.get("v")); - const youtubeRegex = - /^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|embed\/)|youtu\.be\/)/; - if (url.origin.includes("youtu.be")) { - vid = url.pathname.split("/")[1]; - } - // allow autoplay to be specified in the section metadata. - const autoPlay = block.classList.contains("autoplay"); - - if (youtubeRegex.test(url)) { - let dataSource = "https://www.youtube.com"; - dataSource += vid ? "/embed/" + vid + "?rel=0&v=" + vid : embed; - // if autoplay is true, append autoplay to the datasource. - dataSource = - autoPlay ? dataSource + "&autoplay=1&mute=1" : dataSource; - // Render the youtube link through iframe within right container of one of the video carousel slide. - html = `
- - - -
`; - } else { - // Render the url link through video tag within right container of one of the video carousel slide. - // if autoplay is true, add autoplay attribute to the video tag. - html = `
- -
`; - } - return html; - } - //automatic scrolling function advanceSlide() { let slide_selected = block.querySelector(".carousel-circle-selected"); diff --git a/hlx_statics/blocks/columns/columns.css b/hlx_statics/blocks/columns/columns.css index 6a0003ce2..45326b452 100644 --- a/hlx_statics/blocks/columns/columns.css +++ b/hlx_statics/blocks/columns/columns.css @@ -61,7 +61,8 @@ main div.columns-wrapper div.columns div.second-column div.button-group-contai @media screen and (max-width: 620px) { main div.columns-wrapper div.columns div.first-column img, - main div.columns-wrapper div.columns div.first-column video { + main div.columns-wrapper div.columns div.first-column video, + main div.columns-wrapper div.columns div.first-column iframe { width: 100vw; height: auto; } @@ -79,7 +80,8 @@ main div.columns-wrapper div.columns div.first-column { } main div.columns-wrapper div.columns div.first-column img, -main div.columns-wrapper div.columns div.first-column video { +main div.columns-wrapper div.columns div.first-column video, +main div.columns-wrapper div.columns div.first-column iframe { max-width: 100%; max-height: 350px; height: auto; @@ -87,6 +89,32 @@ main div.columns-wrapper div.columns div.first-column video { object-fit: contain; } +main div.columns-wrapper div.columns div.first-column iframe { + width: 100%; + aspect-ratio: 16 / 9; + border: 0; +} + +main div.columns-wrapper div.columns div.first-column .columns-video-slot { + width: 100%; + max-width: 100%; +} + +main div.columns-wrapper div.columns div.first-column .columns-video-slot > div { + width: 100% !important; + height: auto !important; + position: relative !important; + padding-bottom: 56.25% !important; +} + +main div.columns-wrapper div.columns div.first-column .columns-video-slot > div iframe, +main div.columns-wrapper div.columns div.first-column .columns-video-slot > div video { + width: 100% !important; + height: 100% !important; + display: block; + border: 0; +} + main div.columns-wrapper div.second-column { width: 50%; display: flex; @@ -154,7 +182,8 @@ main div.columns-wrapper div.second-column div.product-link-container { width: 100% !important; } main div.columns-wrapper div.columns div.first-column img, - main div.columns-wrapper div.columns div.first-column video { + main div.columns-wrapper div.columns div.first-column video, + main div.columns-wrapper div.columns div.first-column iframe { max-width: 100%; max-height: 350px; height: auto; @@ -282,7 +311,8 @@ main div.columns-wrapper div.vertical .first-column { } main div.columns-wrapper div.columns.vertical div.first-column img, -main div.columns-wrapper div.columns.vertical div.first-column video { +main div.columns-wrapper div.columns.vertical div.first-column video, +main div.columns-wrapper div.columns.vertical div.first-column iframe { max-height: 150px; } diff --git a/hlx_statics/blocks/columns/columns.js b/hlx_statics/blocks/columns/columns.js index 7b11739e3..a9c9b469d 100644 --- a/hlx_statics/blocks/columns/columns.js +++ b/hlx_statics/blocks/columns/columns.js @@ -5,6 +5,12 @@ import { getBlockSectionContainer, decorateAnchorLink, } from '../../scripts/lib-adobeio.js'; +import { + buildFlatVideoIframeHtml, + buildFlatYouTubeIframeHtml, + isVideoUrl, + renderEmbedContent, +} from '../../components/video-embed-utils.js'; import { createOptimizedPicture, @@ -26,6 +32,38 @@ function processImages(block) { }); } +function createVideoSlotContent(videoAnchor) { + const videoUrl = new URL(videoAnchor.href, window.location.href); + if (!isVideoUrl(videoUrl)) return null; + + const title = videoAnchor.textContent?.trim() || 'Video content'; + const wrapper = createTag('div', { class: 'columns-video-slot' }); + const renderResult = renderEmbedContent(videoUrl, { + loop: 1, + controls: 0, + vidTitle: title, + autoplay: 1, + includeDefault: false, + }); + + if (renderResult?.provider === 'youtube') { + // Keep YouTube markup flat in columns so it aligns like image/mp4 cards. + wrapper.classList.add('embed-youtube'); + wrapper.innerHTML = buildFlatYouTubeIframeHtml(videoUrl, title, true); + } else if (renderResult?.html) { + wrapper.innerHTML = renderResult.html; + if (renderResult.className) { + wrapper.classList.add(renderResult.className); + } + } else { + const flatIframe = buildFlatVideoIframeHtml(videoUrl, title); + if (!flatIframe) return null; + wrapper.innerHTML = flatIframe; + } + + return wrapper; +} + /** * loads and decorates the columns * @param {Element} block The columns block element @@ -88,13 +126,14 @@ export default async function decorate(block) { const videoAnchor = slotElements.video?.querySelector('a'); if (videoAnchor) { - const wrapperVideo = createTag('div'); - wrapperVideo.innerHTML = ``; - slotElements.video?.replaceWith(wrapperVideo); - - const newWrapper = createTag('div'); - Array.from(repeatRow.children).forEach((child) => child !== wrapperVideo && newWrapper.appendChild(child) ); - repeatRow.appendChild(newWrapper); + const wrapperVideo = createVideoSlotContent(videoAnchor); + if (wrapperVideo) { + slotElements.video?.replaceWith(wrapperVideo); + + const newWrapper = createTag('div'); + Array.from(repeatRow.children).forEach((child) => child !== wrapperVideo && newWrapper.appendChild(child)); + repeatRow.appendChild(newWrapper); + } } }); } diff --git a/hlx_statics/blocks/embed/embed.js b/hlx_statics/blocks/embed/embed.js index 7b978c82f..7fbcb6135 100644 --- a/hlx_statics/blocks/embed/embed.js +++ b/hlx_statics/blocks/embed/embed.js @@ -4,15 +4,12 @@ * https://www.hlx.live/developer/block-collection/embed */ import { decorateLightOrDark } from '../../scripts/lib-helix.js'; - -/** - * YouTube IFrame embed: for a *single* video, `loop=1` only works together with - * `playlist=`; otherwise the player does not repeat at the end. - * @param {number} loop - * @param {string} [videoId] - */ -const youtubeLoopQuery = (loop, videoId) => - loop && videoId ? `&playlist=${encodeURIComponent(videoId)}` : ''; +import { + mountCarouselVideo, + renderEmbedContent, + resolveEmbedBlockVideo, + resolveVideoLabel, +} from '../../components/video-embed-utils.js'; const loadScript = (url, callback, type) => { const head = document.querySelector('head'); @@ -26,199 +23,15 @@ const loadScript = (url, callback, type) => { return script; }; -const getDefaultEmbed = (url, loop, controls, vidTitle, isShort, autoplay) => { - const params = []; - if (loop) params.push('loop=1'); - if (controls) params.push('controls=1'); - if (autoplay) { - params.push('autoplay=1'); - params.push('mute=1'); - } - const query = params.length ? `?${params.join('&')}` : ''; - const titleAttr = `title="${vidTitle ? vidTitle : `Content from ${url.hostname}`}"`; - const embedHTML = `
- -
`; - return embedHTML; -}; - -const embedIG = (url, loop, controls, vidTitle, isShort, autoplay) => { - const link = url.href.split('?')[0] + 'embed/captioned'; - const embedHTML = `
- -
`; -loadScript("https://www.instagram.com/embed.js"); -return embedHTML; -}; - -const embedYTShort = (url, loop, controls, vidTitle, autoplay) => { - const [, videoCode] = url.pathname.split('/shorts/'); - const mute = autoplay ? '&mute=1' : ''; - const loopQs = youtubeLoopQuery(loop, videoCode); - return `
- -
`; -}; - -const embedMP4 = (url, loop, controls, vidTitle, isShort, autoplay) => { - const href = url instanceof URL ? url.href : String(url); - const autoplayMute = autoplay ? 'autoplay muted playsinline' : ''; - const video = ` -
- -
- `; - return video; -}; -const embedYTPlaylist = (url, loop, controls, vidTitle, isShort, autoplay) => { - const listId = url.searchParams.get('list'); - const params = new URLSearchParams({ list: listId || '' }); - params.set('loop', String(loop)); - params.set('controls', String(controls)); - if (autoplay) { - params.set('autoplay', '1'); - params.set('mute', '1'); - } - const src = `https://www.youtube-nocookie.com/embed/videoseries?${params.toString()}`; - const embedHTML = `
- -
`; - return embedHTML; -}; -const embedTikTok = (url, loop, controls, vidTitle, isShort, autoplay) => { - const [, vidID] = url.pathname.split('video/') - return `
- -
`; -} - -const embedYoutube = (url, loop, controls, vidTitle, isShort, autoplay) => { - let vid; - const embed = url.pathname; - - if (url.hostname === 'www.youtube.com' || url.hostname === 'youtube.com') { - const usp = new URLSearchParams(url.search); - vid = usp.get('v') || (embed.includes('embed') && embed.split('/')[2]); - } - - if (url.hostname === 'youtu.be') { - vid = embed.split('/')[1]; - } - if (embed.includes('shorts')) { - return embedYTShort(url, loop, controls, vidTitle, autoplay); - } - if (embed.includes('playlist')) { - return embedYTPlaylist(url, loop, controls, vidTitle, isShort, autoplay ); - } - if (isShort && vid) { - const mute = autoplay ? '&mute=1' : ''; - const loopQs = youtubeLoopQuery(loop, vid); - return `
- -
`; - } - - if (vid) { - const mute = autoplay ? '&mute=1' : ''; - const loopQs = youtubeLoopQuery(loop, vid); - return ` -
- -
- `; - } - - return null; -}; - -const embedVimeo = (url, loop, controls, vidTitle, isShort, autoplay) => { - const [, video] = url.pathname.split('/'); - const muted = autoplay ? '&muted=1' : ''; - const embedHTML = `
- -
`; - return embedHTML; -}; -const embedTwitter = (url, loop, controls, vidTitle, isShort, autoplay) => { - const source = url.protocol + "//twitter.com" + url.pathname + (url.search ? url.search : ""); - const embedHTML = `
`; - loadScript('https://platform.twitter.com/widgets.js'); - return embedHTML; -}; - - -const loadEmbed = (block, link) => { +const loadEmbed = (block, link, vidTitle = '') => { if (block.classList.contains('embed-is-loaded')) { return; } decorateLightOrDark(block, true); - const EMBEDS_CONFIG = [ - { - match: ['youtube', 'youtu.be'], - embed: embedYoutube, - }, - { - match: ['vimeo'], - embed: embedVimeo, - }, - { - match: ['twitter', 'x.com'], - embed: embedTwitter, - }, - { - match: ['insta'], - embed: embedIG, - }, - { - match: ['tiktok'], - embed: embedTikTok, - }, - { - match: ['mp4'], - embed: embedMP4, - }, - ]; - const config = EMBEDS_CONFIG.find((e) => e.match.some((match) => link.includes(match))); // Initially set so that looping does not occur, but user can view the controls let loop = 0; let controls = 1; let autoplay = 0; - const vidTitle = block.getAttribute( 'data-videotitle'); const isShort = block.getAttribute('data-short')?.toLowerCase() === 'true'; // changes the values based on metadata on this block or an ancestor section if (block.getAttribute('data-loop') === 'true' || block.classList.contains('loop')) { @@ -233,13 +46,19 @@ const loadEmbed = (block, link) => { if (controls === 0 ) { autoplay = 1; } - const url = new URL(link); - if (config) { - block.innerHTML = config.embed(url, loop, controls, vidTitle, isShort, autoplay); - block.classList.add('block', 'embed', `embed-${config.match[0]}`); - } else { - block.innerHTML = getDefaultEmbed(url, loop, controls, vidTitle, isShort, autoplay); - block.classList.add('block', 'embed'); + const renderResult = renderEmbedContent(link, { + loop, + controls, + vidTitle, + isShort, + autoplay, + includeDefault: true, + loadScript, + }); + block.innerHTML = renderResult?.html || ''; + block.classList.add('block', 'embed'); + if (renderResult?.className) { + block.classList.add(renderResult.className); } block.classList.add('embed-is-loaded'); const videoListener = () => { @@ -258,38 +77,56 @@ const loadEmbed = (block, link) => { } }; -const addImage = (placeholder, block, link) => { +const addImage = (placeholder, block, link, vidTitle) => { const wrapper = document.createElement('div'); wrapper.className = 'embed-placeholder'; wrapper.innerHTML = '
'; wrapper.prepend(placeholder); wrapper.addEventListener('click', () => { - loadEmbed(block, link, true); + loadEmbed(block, link, vidTitle); }); block.append(wrapper); }; export default function decorate(block) { - const getParent = block.parentElement; + const carouselBlock = block.closest('.carousel'); + if (carouselBlock) { + const slideCell = block.closest('.carousel-container'); + if (slideCell && !slideCell.querySelector(':scope > .video-element')) { + const resolved = resolveEmbedBlockVideo(block); + if (resolved) { + mountCarouselVideo( + carouselBlock, + slideCell, + block, + resolved.urlString, + resolved.title, + ); + } + } + return; + } + block.setAttribute('daa-lh', 'embed'); const placeholder = block.querySelector('picture'); - let link - if (block.querySelector('a')?.href) { - link = block.querySelector('a')?.href - } - else { - link = block.querySelector('.embed > div > div').innerText; + const anchor = block.querySelector('a'); + const vidTitle = block.getAttribute('data-videotitle') || resolveVideoLabel(anchor); + let link; + if (anchor?.href) { + link = anchor.href; + } else { + link = block.querySelector('.embed > div > div')?.innerText; } block.textContent = ''; if (placeholder) { if (!(placeholder.alt)) placeholder.alt = "Content thumbnail"; - addImage(placeholder, block, link); + addImage(placeholder, block, link, vidTitle); } else { const observer = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)){ observer.disconnect(); - loadEmbed(block, link); + loadEmbed(block, link, vidTitle); } }); observer.observe(block); diff --git a/hlx_statics/blocks/superhero/superhero.css b/hlx_statics/blocks/superhero/superhero.css index 05e5911e1..74894a32c 100644 --- a/hlx_statics/blocks/superhero/superhero.css +++ b/hlx_statics/blocks/superhero/superhero.css @@ -151,12 +151,32 @@ main div.superhero-wrapper div.superhero.half-width { } main div.superhero-wrapper div.superhero.half-width > div:last-child img, -main div.superhero-wrapper div.superhero.half-width > div:last-child video { +main div.superhero-wrapper div.superhero.half-width > div:last-child video, +main div.superhero-wrapper div.superhero.half-width .superhero-video-container { max-width: 100% !important; + width: 100%; +} + +main div.superhero-wrapper div.superhero.half-width > div:last-child video { max-height: 350px !important; object-fit: contain !important; } +main div.superhero-wrapper div.superhero.half-width .superhero-video-container.embed-youtube iframe { + width: 100%; + max-width: 100%; + aspect-ratio: 16 / 9; + height: auto; + border: 0; + display: block; +} + +main div.superhero-wrapper div.superhero.half-width .superhero-video-container:not(.embed-youtube) iframe, +main div.superhero-wrapper div.superhero.half-width .superhero-video-container:not(.embed-youtube) > div { + width: 100% !important; + max-width: 100%; +} + main div.superhero-wrapper div.superhero.half-width p.spectrum-Body--sizeL { margin-top: 0 !important; color: rgb(80, 80, 80) !important; diff --git a/hlx_statics/blocks/superhero/superhero.js b/hlx_statics/blocks/superhero/superhero.js index 70cfc514f..c35c2fbfb 100644 --- a/hlx_statics/blocks/superhero/superhero.js +++ b/hlx_statics/blocks/superhero/superhero.js @@ -1,6 +1,12 @@ import { removeEmptyPTags, decorateButtons, createTag } from '../../scripts/lib-adobeio.js'; import { decorateLightOrDark } from '../../scripts/lib-helix.js'; import { insertWrapperChild } from '../../components/wrapperContainer.js'; +import { + buildBlockVideoMediaHtml, + isVideoAnchor, + resolveVideoLabel, + resolveVideoUrl, +} from '../../components/video-embed-utils.js'; const VARIANTS = { default: 'default', @@ -126,20 +132,45 @@ async function decorateDevBizHalfWidth(block) { placeholderDiv.remove(); } - const videoURL = block.lastElementChild.querySelector('a'); - if (videoURL && block.classList.contains('video')) { - const isControl = block.classList.contains('controls'); - const wantAutoplay = block.classList.contains('autoplay'); - const wantLoop = block.classList.contains('loop'); - const isAutoplay = !isControl || wantAutoplay; - const isLoop = !isControl || wantLoop; - const muted = !isControl || wantAutoplay; - - const videoContainer = createTag('div', { class: 'superhero-video-container' }); - const videoTag = ``; - videoContainer.innerHTML = videoTag; - block.lastElementChild.replaceWith(videoContainer); + mountSuperheroVideoMedia(block); +} + +function mountSuperheroVideoMedia(block) { + if (!block.classList.contains('video')) return; + + const mediaColumn = block.lastElementChild; + const videoAnchor = mediaColumn?.querySelector('a'); + if (!videoAnchor || !isVideoAnchor(videoAnchor)) return; + + const urlString = resolveVideoUrl(videoAnchor); + if (!urlString) return; + + const isControl = block.classList.contains('controls'); + const wantAutoplay = block.classList.contains('autoplay'); + const wantLoop = block.classList.contains('loop'); + const isAutoplay = !isControl || wantAutoplay; + const isLoop = !isControl || wantLoop; + const muted = !isControl || wantAutoplay; + + const built = buildBlockVideoMediaHtml( + new URL(urlString, window.location.href), + resolveVideoLabel(videoAnchor), + { + autoplay: isAutoplay, + controls: isControl, + loop: isLoop, + muted, + }, + ); + + if (!built?.html) return; + + const videoContainer = createTag('div', { class: 'superhero-video-container' }); + if (built.className) { + videoContainer.classList.add(built.className); } + videoContainer.innerHTML = built.html; + mediaColumn.replaceWith(videoContainer); } async function decorateDevBizDefault(block) { diff --git a/hlx_statics/blocks/text/text.css b/hlx_statics/blocks/text/text.css index 2e3ab905e..32b2d1f46 100644 --- a/hlx_statics/blocks/text/text.css +++ b/hlx_statics/blocks/text/text.css @@ -63,10 +63,29 @@ main div.text-wrapper .text-block-link { color: #2980e8; } +main div.text-wrapper .text-video-slot, +main div.text-wrapper .videoIFrame { + width: 100%; + max-width: 100%; +} + +main div.text-wrapper .text-video-slot video, +main div.text-wrapper .text-video-slot iframe, main div.text-wrapper .videoIFrame { width: 100%; - height: 425px; border: none; + display: block; +} + +main div.text-wrapper .text-video-slot.embed-youtube iframe, +main div.text-wrapper .videoIFrame { + aspect-ratio: 16 / 9; + height: auto; +} + +main div.text-wrapper .text-video-slot video { + height: auto; + max-height: 425px; } main div.text-wrapper .text-button-container { diff --git a/hlx_statics/blocks/text/text.js b/hlx_statics/blocks/text/text.js index 986798998..481331e18 100644 --- a/hlx_statics/blocks/text/text.js +++ b/hlx_statics/blocks/text/text.js @@ -1,9 +1,18 @@ import { - decorateButtons + decorateButtons, } from '../../scripts/lib-adobeio.js'; +import { + buildFlatYouTubeIframeHtml, + getVideoProvider, + isDirectVideoUrl, + renderEmbedContent, +} from '../../components/video-embed-utils.js'; + +/** Text blocks only auto-embed standalone video links (not social/profile URLs). */ +const TEXT_BLOCK_VIDEO_PROVIDERS = new Set(['youtube', 'vimeo', 'mp4']); function rearrangeLinks(block) { - const contentDiv = block.firstElementChild.querySelectorAll(`div:has(p)`); + const contentDiv = block.firstElementChild.querySelectorAll('div:has(p)'); const textLinkContainer = document.createElement('div'); textLinkContainer.classList.add('link-list-container'); const contentContainer = document.createElement('div'); @@ -13,20 +22,92 @@ function rearrangeLinks(block) { textLinkContainer.append(p); }); div.append(textLinkContainer); - contentContainer.append(div) - }) + contentContainer.append(div); + }); block.firstElementChild.append(contentContainer); } -function videoConverter(div) { - const iFrame = document.createElement('iframe'); - iFrame.setAttribute('src', `${div.firstElementChild.href}`); - iFrame.classList.add('videoIFrame'); - iFrame.setAttribute('allow', 'autoplay'); - div.append(iFrame); - div.firstElementChild.remove(); + +function isStandaloneVideoParagraph(anchor) { + const paragraph = anchor.closest('p'); + if (!paragraph) return false; + if (paragraph.classList.contains('button-container')) return true; + + return [...paragraph.childNodes].every((node) => { + if (node === anchor) return true; + return node.nodeType === Node.TEXT_NODE && !node.textContent.trim(); + }); +} + +function resolveTextBlockVideoUrl(anchor) { + const candidates = [ + anchor.href, + anchor.textContent?.trim(), + anchor.getAttribute('title'), + ].filter(Boolean); + + for (const candidate of candidates) { + const httpsMatch = String(candidate).match(/https?:\/\/[^\s"'<>]+/i); + const raw = (httpsMatch ? httpsMatch[0] : candidate).replace(/[.,;:!?)]+$/, ''); + try { + const url = new URL(raw, window.location.href); + if (isDirectVideoUrl(url)) return url.href; + const provider = getVideoProvider(url); + if (provider && TEXT_BLOCK_VIDEO_PROVIDERS.has(provider)) return url.href; + if (url.hostname.toLowerCase() === 'video.tv.adobe.com') return url.href; + } catch { + // try next candidate + } + } + return null; } + +function mountTextBlockVideo(anchor, urlString) { + if (!urlString) return; + + const url = new URL(urlString, window.location.href); + const title = anchor.textContent?.trim() || 'Video content'; + const slot = document.createElement('div'); + slot.classList.add('text-video-slot'); + + const provider = getVideoProvider(url); + if (provider === 'youtube') { + slot.classList.add('embed-youtube'); + slot.innerHTML = buildFlatYouTubeIframeHtml(url, title, false); + } else if (isDirectVideoUrl(url)) { + slot.innerHTML = ``; + } else { + const rendered = renderEmbedContent(url, { + loop: 0, + controls: 1, + autoplay: 0, + includeDefault: true, + vidTitle: title, + }); + if (!rendered?.html) return; + slot.innerHTML = rendered.html; + if (rendered.className) { + slot.classList.add(rendered.className); + } + } + + const container = anchor.closest('p') || anchor.parentElement; + container?.replaceWith(slot); +} + +function processTextBlockVideos(block) { + block.querySelectorAll('a').forEach((anchor) => { + if (anchor.closest('.text-video-slot')) return; + if (!isStandaloneVideoParagraph(anchor)) return; + const urlString = resolveTextBlockVideoUrl(anchor); + if (!urlString) return; + mountTextBlockVideo(anchor, urlString); + }); +} + function rearrangeButtons(block) { - const contentDiv = block.firstElementChild.querySelectorAll(`div:has(p)`); + const contentDiv = block.firstElementChild.querySelectorAll('div:has(p)'); const textButtonContainer = document.createElement('div'); textButtonContainer.classList.add('text-button-container'); const contentContainer = document.createElement('div'); @@ -36,10 +117,18 @@ function rearrangeButtons(block) { textButtonContainer.append(p); }); div.append(textButtonContainer); - contentContainer.append(div) - }) + contentContainer.append(div); + }); block.firstElementChild.append(contentContainer); } + +function hasTextBlockMedia(block) { + return Boolean( + block.querySelector('.text-video-slot') + || block.querySelector('p.button-container'), + ); +} + /** * decorates the text * @param {*} block The text block element @@ -57,46 +146,41 @@ export default async function decorate(block) { p.classList.add('text-block-link'); }); block.querySelectorAll('p a:first-child').forEach((p) => { - p.style.borderWidth = "2px" + p.style.borderWidth = '2px'; }); block.querySelectorAll('img').forEach((img) => { img.classList.add('textImg'); }); - let isImageTextBlock = true - Array.from(block.firstElementChild.children).forEach((div) => { - if (div.classList.contains("button-container")) { //FIXME: checking "button-container" before decorateButtons() - videoConverter(div); - decorateButtons(block); - isImageTextBlock = false; - } - }); + decorateButtons(block); + processTextBlockVideos(block); + + const isImageTextBlock = !hasTextBlockMedia(block); if (isImageTextBlock) { rearrangeLinks(block); - } - else { + } else { rearrangeButtons(block); document.querySelectorAll('.text .contentContainer').forEach((contentContainer) => { - if (contentContainer.firstElementChild.firstElementChild.tagName.toLowerCase() === 'p') { - const contentContainerElements = contentContainer.firstElementChild.children; - const newDiv = document.createElement('div'); - newDiv.classList.add('headIconContainer'); - for (let i = 0; i < contentContainerElements.length; i++) { - if (contentContainerElements[i].tagName.toLowerCase() === 'p') { - if (newDiv.children.length > 0) { - const spliterDiv = document.createElement('div'); - spliterDiv.classList.add('spliterDiv'); - newDiv.append(spliterDiv); - } - newDiv.append(contentContainerElements[i]); - i -= 1; - } else { - break; + const firstChild = contentContainer.firstElementChild?.firstElementChild; + if (!firstChild || firstChild.tagName.toLowerCase() !== 'p') return; + + const contentContainerElements = contentContainer.firstElementChild.children; + const newDiv = document.createElement('div'); + newDiv.classList.add('headIconContainer'); + for (let i = 0; i < contentContainerElements.length; i += 1) { + if (contentContainerElements[i].tagName.toLowerCase() === 'p') { + if (newDiv.children.length > 0) { + const spliterDiv = document.createElement('div'); + spliterDiv.classList.add('spliterDiv'); + newDiv.append(spliterDiv); } + newDiv.append(contentContainerElements[i]); + i -= 1; + } else { + break; } - contentContainer.firstElementChild.insertBefore(newDiv, contentContainerElements[0]); } - }) + contentContainer.firstElementChild.insertBefore(newDiv, contentContainerElements[0]); + }); } - decorateButtons(block); } diff --git a/hlx_statics/components/video-embed-utils.js b/hlx_statics/components/video-embed-utils.js new file mode 100644 index 000000000..392beda4a --- /dev/null +++ b/hlx_statics/components/video-embed-utils.js @@ -0,0 +1,581 @@ +function toUrl(linkOrUrl) { + if (linkOrUrl instanceof URL) return linkOrUrl; + return new URL(linkOrUrl, window.location.href); +} + +/** Escape text for safe use inside double-quoted HTML attributes. */ +export function escapeAttr(value) { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/]+/i); + if (httpsMatch && isVideoUrl(httpsMatch[0])) { + return httpsMatch[0].replace(/[.,;:!?)]+$/, ''); + } + if (isVideoUrl(candidate)) { + return candidate.startsWith('http') + ? candidate + : new URL(candidate, window.location.href).href; + } + } + return null; +} + +export function isVideoAnchor(anchor) { + return Boolean(resolveVideoUrl(anchor)); +} + +/** + * Resolves a video URL from an embed block (anchor or plain text in cells). + * @param {Element} embedBlock + * @returns {{ urlString: string, title: string }|null} + */ +export function resolveEmbedBlockVideo(embedBlock) { + if (!embedBlock) return null; + + const anchor = embedBlock.querySelector('a'); + if (anchor) { + const urlString = resolveVideoUrl(anchor); + if (urlString) { + return { + urlString, + title: resolveVideoLabel(anchor), + }; + } + } + + const cell = embedBlock.querySelector(':scope > div > div'); + const text = cell?.textContent?.trim() || ''; + const match = text.match(/https?:\/\/[^\s"'<>]+/i); + if (match && isVideoUrl(match[0])) { + return { + urlString: match[0].replace(/[.,;:!?)]+$/, ''), + title: 'Video content', + }; + } + + return null; +} + +export function getVideoProvider(linkOrUrl) { + const url = toUrl(linkOrUrl); + const host = url.hostname.toLowerCase(); + + if (isDirectVideoUrl(url)) return 'mp4'; + if (host === 'youtu.be' || host === 'm.youtube.com' || host.endsWith('youtube.com')) { + return 'youtube'; + } + if (host.includes('vimeo.com')) return 'vimeo'; + if (host === 'x.com' || host.endsWith('twitter.com')) return 'twitter'; + if (host.includes('instagram.com')) return 'insta'; + if (host.includes('tiktok.com')) return 'tiktok'; + + return null; +} + +/** + * YouTube IFrame embed: for a *single* video, `loop=1` only works together with + * `playlist=`; otherwise the player does not repeat at the end. + * @param {number} loop + * @param {string} [videoId] + */ +function youtubeLoopQuery(loop, videoId) { + return loop && videoId ? `&playlist=${encodeURIComponent(videoId)}` : ''; +} + +function getDefaultEmbed(url, loop, controls, vidTitle, autoplay) { + const params = []; + if (loop) params.push('loop=1'); + if (controls) params.push('controls=1'); + if (autoplay) { + params.push('autoplay=1'); + params.push('mute=1'); + } + const query = params.length ? `?${params.join('&')}` : ''; + const titleAttr = `title="${vidTitle || escapeAttr(`Content from ${url.hostname}`)}"`; + return `
+ +
`; +} + +function embedIG(url, vidTitle, autoplay, loadScript) { + const link = `${url.href.split('?')[0]}embed/captioned`; + if (typeof loadScript === 'function') { + loadScript('https://www.instagram.com/embed.js'); + } + return `
+ +
`; +} + +function embedYTShort(url, loop, controls, vidTitle, autoplay) { + const [, videoCode] = url.pathname.split('/shorts/'); + const mute = autoplay ? '&mute=1' : ''; + const loopQs = youtubeLoopQuery(loop, videoCode); + return `
+ +
`; +} + +function embedMP4(url, loop, controls, autoplay) { + const autoplayMute = autoplay ? 'autoplay muted playsinline' : ''; + return ` +
+ +
+ `; +} + +function embedYTPlaylist(url, loop, controls, vidTitle, autoplay) { + const listId = url.searchParams.get('list'); + const params = new URLSearchParams({ list: listId || '' }); + params.set('loop', String(loop)); + params.set('controls', String(controls)); + if (autoplay) { + params.set('autoplay', '1'); + params.set('mute', '1'); + } + const src = `https://www.youtube-nocookie.com/embed/videoseries?${params.toString()}`; + return `
+ +
`; +} + +function embedTikTok(url, vidTitle, autoplay) { + const [, vidID] = url.pathname.split('video/'); + return `
+ +
`; +} + +function embedYoutube(url, loop, controls, vidTitle, isShort, autoplay) { + let vid; + const embedPath = url.pathname; + const host = url.hostname.toLowerCase(); + + if (host === 'www.youtube.com' || host === 'youtube.com') { + const usp = new URLSearchParams(url.search); + vid = usp.get('v') || (embedPath.includes('embed') && embedPath.split('/')[2]); + } + + if (host === 'youtu.be') { + vid = embedPath.split('/').filter(Boolean)[0]?.split('?')[0] || ''; + } + if (embedPath.includes('shorts')) { + return embedYTShort(url, loop, controls, vidTitle, autoplay); + } + if (embedPath.includes('playlist')) { + return embedYTPlaylist(url, loop, controls, vidTitle, autoplay); + } + if (isShort && vid) { + const mute = autoplay ? '&mute=1' : ''; + const loopQs = youtubeLoopQuery(loop, vid); + return `
+ +
`; + } + + if (vid) { + const mute = autoplay ? '&mute=1' : ''; + const loopQs = youtubeLoopQuery(loop, vid); + return ` +
+ +
+ `; + } + + return null; +} + +function embedVimeo(url, loop, controls, vidTitle, autoplay) { + const [, video] = url.pathname.split('/'); + const muted = autoplay ? '&muted=1' : ''; + return `
+ +
`; +} + +function embedTwitter(url, vidTitle, autoplay, loadScript) { + const source = `${url.protocol}//twitter.com${url.pathname}${url.search ? url.search : ''}`; + if (typeof loadScript === 'function') { + loadScript('https://platform.twitter.com/widgets.js'); + } + return ``; +} + +/** + * Resolves display title/alt from a markdown-style video link. + * @param {HTMLAnchorElement} anchor + * @returns {string} + */ +export function resolveVideoLabel(anchor) { + if (!anchor) return 'Video content'; + const linkText = anchor.textContent?.trim() || ''; + const isUrlLike = isVideoUrl(linkText) || /^https?:\/\//i.test(linkText); + if (!isUrlLike && linkText) return linkText; + return anchor.getAttribute('title')?.trim() || 'Video content'; +} + +/** + * Builds video markup for block media slots (superhero, etc.). + * @param {string|URL} linkOrUrl + * @param {string} title + * @param {{ autoplay?: boolean, controls?: boolean, loop?: boolean, muted?: boolean }} options + * @returns {{ html: string, className: string }|null} + */ +export function buildBlockVideoMediaHtml(linkOrUrl, title = 'Video content', options = {}) { + const { + autoplay = false, + controls = true, + loop = false, + muted = false, + } = options; + + const url = toUrl(linkOrUrl); + const provider = getVideoProvider(url); + + if (provider === 'youtube') { + return { + html: buildFlatYouTubeIframeHtml(url, title, autoplay), + className: 'embed-youtube', + }; + } + + if (isDirectVideoUrl(url)) { + const attrs = [ + controls ? 'controls' : '', + autoplay ? 'autoplay' : '', + muted || autoplay ? 'muted' : '', + loop ? 'loop' : '', + 'playsinline', + 'preload="metadata"', + `title="${escapeAttr(title)}"`, + ].filter(Boolean).join(' '); + + return { + html: ``, + className: 'superhero-video-mp4', + }; + } + + const rendered = renderEmbedContent(url, { + loop: loop ? 1 : 0, + controls: controls ? 1 : 0, + autoplay: autoplay ? 1 : 0, + includeDefault: true, + vidTitle: title, + }); + + if (rendered?.html) { + return { + html: rendered.html, + className: rendered.className || '', + }; + } + + return null; +} + +export function buildFlatYouTubeIframeHtml(linkOrUrl, title = 'Video content', autoplay = false) { + const embedUrl = getYouTubeEmbedUrl(linkOrUrl); + if (!embedUrl) return ''; + + const queryIndex = embedUrl.indexOf('?'); + const base = queryIndex === -1 ? embedUrl : embedUrl.slice(0, queryIndex); + const params = new URLSearchParams(queryIndex === -1 ? '' : embedUrl.slice(queryIndex + 1)); + + if (autoplay) { + params.set('autoplay', '1'); + params.set('mute', '1'); + } + + const query = params.toString(); + const src = query ? `${base}?${query}` : base; + + return ``; +} + +export function buildFlatVideoIframeHtml(linkOrUrl, title = 'Video content') { + const src = getEmbeddableVideoUrl(linkOrUrl); + if (!src) return ''; + return ``; +} + +export function buildCarouselVideoHtml(carouselBlock, url, title = 'Video content') { + const autoPlay = carouselBlock?.classList?.contains('autoplay'); + const provider = getVideoProvider(url); + + if (provider === 'youtube') { + return buildFlatYouTubeIframeHtml(url, title, autoPlay); + } + + const rendered = renderEmbedContent(url, { + loop: 0, + controls: 1, + autoplay: autoPlay ? 1 : 0, + includeDefault: true, + vidTitle: title, + }); + + if (rendered?.html) { + return wrapCarouselVideoEmbed(rendered.html); + } + + if (isDirectVideoUrl(url)) { + return wrapCarouselVideoEmbed( + ``, + ); + } + + return wrapCarouselVideoEmbed( + ``, + ); +} + +/** + * Mounts a video in a carousel slide media column. + * @param {Element} carouselBlock + * @param {Element} slideCell + * @param {Element|null} container Node to remove after mount (embed block, paragraph, etc.) + * @param {string} urlString + */ +export function mountCarouselVideo( + carouselBlock, + slideCell, + container, + urlString, + title = 'Video content', +) { + if (!slideCell || !urlString || slideCell.querySelector(':scope > .video-element')) return; + try { + const url = new URL(urlString, window.location.href); + const provider = getVideoProvider(url); + const videoElement = document.createElement('div'); + videoElement.className = 'video-element'; + if (provider === 'youtube') { + videoElement.classList.add('embed-youtube'); + } + const html = buildCarouselVideoHtml(carouselBlock, url, title); + if (!html?.trim()) return; + videoElement.innerHTML = html; + slideCell.insertBefore(videoElement, slideCell.firstChild); + container?.remove(); + const embedBlock = container?.classList?.contains('block') + && container?.classList?.contains('embed') + ? container + : container?.closest?.('.embed.block'); + if (embedBlock) { + embedBlock.classList.add('embed-is-loaded'); + embedBlock.dataset.carouselVideo = 'true'; + } + } catch (error){ + // invalid URL — leave content unchanged + console.warn('mountCarouselVideo: could not mount video for', urlString, error); + } +} + +export function wrapCarouselVideoEmbed(html) { + if (!html) return ''; + + const temp = document.createElement('div'); + temp.innerHTML = html.trim(); + const iframe = temp.querySelector('iframe'); + const video = temp.querySelector('video'); + + if (iframe) { + const src = iframe.getAttribute('src') || iframe.getAttribute('data-src'); + if (src) { + iframe.setAttribute('src', src); + iframe.removeAttribute('data-src'); + } + iframe.setAttribute('loading', 'lazy'); + return ``; + } + + if (video) { + return ``; + } + + const inner = temp.querySelector(':scope > div') || temp.firstElementChild; + if (inner) { + return ``; + } + + return ``; +} + +export function renderEmbedContent(linkOrUrl, options = {}) { + const { + loop = 0, + controls = 1, + vidTitle = '', + isShort = false, + autoplay = 0, + includeDefault = true, + loadScript, + } = options; + + const url = toUrl(linkOrUrl); + const provider = getVideoProvider(url); + const safeVidTitle = vidTitle ? escapeAttr(vidTitle) : ''; + + if (provider === 'youtube') { + return { + html: embedYoutube(url, loop, controls, safeVidTitle, isShort, autoplay), + className: 'embed-youtube', + provider, + }; + } + if (provider === 'vimeo') { + return { + html: embedVimeo(url, loop, controls, safeVidTitle, autoplay), + className: 'embed-vimeo', + provider, + }; + } + if (provider === 'twitter') { + return { + html: embedTwitter(url, safeVidTitle, autoplay, loadScript), + className: 'embed-twitter', + provider, + }; + } + if (provider === 'insta') { + return { + html: embedIG(url, safeVidTitle, autoplay, loadScript), + className: 'embed-insta', + provider, + }; + } + if (provider === 'tiktok') { + return { + html: embedTikTok(url, safeVidTitle, autoplay), + className: 'embed-tiktok', + provider, + }; + } + if (provider === 'mp4') { + return { + html: embedMP4(url, loop, controls, autoplay), + className: 'embed-mp4', + provider, + }; + } + + if (!includeDefault) return null; + + return { + html: getDefaultEmbed(url, loop, controls, safeVidTitle, autoplay), + className: '', + provider: null, + }; +}