From a4ed52156ac47823926facd9d617a8832dd7000d Mon Sep 17 00:00:00 2001 From: fkakatie Date: Thu, 11 Jun 2026 22:54:55 -0600 Subject: [PATCH 1/3] feat: video-specific youtube icon --- blocks/video/youtube.svg | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 blocks/video/youtube.svg diff --git a/blocks/video/youtube.svg b/blocks/video/youtube.svg new file mode 100644 index 0000000..b65fdbc --- /dev/null +++ b/blocks/video/youtube.svg @@ -0,0 +1,4 @@ + + + + From caa8622ea9348c5cb184f8819ec50fe80d92544d Mon Sep 17 00:00:00 2001 From: fkakatie Date: Thu, 11 Jun 2026 22:55:13 -0600 Subject: [PATCH 2/3] feat: video block with youtube support --- blocks/video/video.css | 78 +++++++++++++++++++++++++++++++++++++ blocks/video/video.js | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 blocks/video/video.css create mode 100644 blocks/video/video.js diff --git a/blocks/video/video.css b/blocks/video/video.css new file mode 100644 index 0000000..52ad027 --- /dev/null +++ b/blocks/video/video.css @@ -0,0 +1,78 @@ +.video { + position: relative; +} + +.video iframe { + display: block; + margin: 0 auto; + width: min(100%, calc((100dvh - var(--header-height) - (2 * var(--space-m))) * 16 / 9)); + aspect-ratio: 16 / 9; + border: none; + border-radius: 0; + transition: border-radius 0.8s; +} + +.video:has(.placeholder) iframe { + border-radius: var(--radius-m); +} + +.video .placeholder { + position: absolute; + top: 0; + left: 50%; + width: min(100%, calc((100dvh - var(--header-height) - (2 * var(--space-m))) * 16 / 9)); + aspect-ratio: 16 / 9; + margin: 0; + border-radius: var(--radius-m); + transform: translateX(-50%); + cursor: pointer; + transition: box-shadow 0.2s; +} + +.video .placeholder:hover, +.video .placeholder:focus-visible { + box-shadow: var(--shadow-m); +} + +.video .placeholder img { + width: 100%; + height: 100%; + border-radius: var(--radius-m); + object-fit: cover; +} + +.video .placeholder::before, +.video .placeholder::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80px; + height: 80px; + background-color: currentcolor; + transition: transform 0.2s; +} + +.video .placeholder:hover::before, +.video .placeholder:hover::after, +.video .placeholder:focus-visible::before, +.video .placeholder:focus-visible::after { + transform: translate(-50%, -50%) scale(1.2) ; +} + +.video[data-source="youtube"] .placeholder::before { + z-index: 1; + background-color: #f00; + mask-image: url('./youtube.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.video[data-source="youtube"] .placeholder::after { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: white; +} diff --git a/blocks/video/video.js b/blocks/video/video.js new file mode 100644 index 0000000..f731b4e --- /dev/null +++ b/blocks/video/video.js @@ -0,0 +1,87 @@ +/** + * Builds a YouTube iframe from a youtube.com or youtu.be URL. + * @param {string} url - the video page or short URL to parse + * @returns {HTMLIFrameElement|null} configured embed iframe + */ +function createYouTubeEmbed(url) { + const { hostname, pathname, searchParams } = new URL(url); + const isYouTube = hostname === 'youtu.be' || hostname.endsWith('youtube.com'); + if (!isYouTube) return null; + + const id = hostname === 'youtu.be' ? pathname.slice(1) : searchParams.get('v'); + if (!id) return null; + + const iframe = document.createElement('iframe'); + iframe.src = `https://www.youtube.com/embed/${id}`; + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); + iframe.title = 'YouTube video'; + return iframe; +} + +/** + * Builds a thumbnail overlay that removes itself and starts embed playback when activated. + * @param {HTMLElement} block - block element containing the authored thumbnail image + * @param {HTMLIFrameElement} embed - video iframe; receives autoplay=1 on activation + * @returns {HTMLElement|null} the placeholder figure, or null if no image is found in block + */ +function createPlaceholder(block, embed) { + const image = block.querySelector('picture, img'); + if (!image) return null; + + const figure = document.createElement('figure'); + figure.classList.add('placeholder'); + figure.setAttribute('role', 'button'); + figure.setAttribute('tabindex', '0'); + figure.append(image); + + function play() { + const src = new URL(embed.src); + src.searchParams.set('autoplay', '1'); + embed.src = src.href; + figure.remove(); + } + + figure.addEventListener('click', play); + figure.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + play(); + } + }); + + return figure; +} + +/** + * Tries each registered provider against url and returns the matched embed and provider name. + * @param {string} url - the video URL to match against registered providers + * @returns {{ embed: HTMLIFrameElement|null, source: string|null }} embed element and provider name + */ +function createEmbed(url) { + const providers = [['youtube', createYouTubeEmbed]]; + let embed = null; + let source = null; + providers.some(([name, create]) => { + embed = create(url); + if (embed) source = name; + return embed; + }); + return { embed, source }; +} + +export default function decorate(block) { + const link = block.querySelector('a[href]'); + if (!link) { + const wrapper = block.closest('.video-wrapper'); + if (wrapper) wrapper.remove(); + return; + } + + const { embed, source } = createEmbed(link.href); + if (!embed) return; + block.dataset.source = source; + const placeholder = createPlaceholder(block, embed); + + block.replaceChildren(...[embed, placeholder].filter(Boolean)); +} From addc216a92373ead50fc8c54ecf07eb4e1d98e9a Mon Sep 17 00:00:00 2001 From: fkakatie Date: Thu, 11 Jun 2026 23:16:57 -0600 Subject: [PATCH 3/3] fix: performance attempts --- blocks/video/video.css | 18 ++++----- blocks/video/video.js | 85 +++++++++++++++++++++++++++--------------- 2 files changed, 63 insertions(+), 40 deletions(-) diff --git a/blocks/video/video.css b/blocks/video/video.css index 52ad027..1c2f67a 100644 --- a/blocks/video/video.css +++ b/blocks/video/video.css @@ -1,12 +1,14 @@ .video { - position: relative; -} - -.video iframe { display: block; margin: 0 auto; + position: relative; width: min(100%, calc((100dvh - var(--header-height) - (2 * var(--space-m))) * 16 / 9)); aspect-ratio: 16 / 9; +} + +.video iframe { + width: 100%; + height: 100%; border: none; border-radius: 0; transition: border-radius 0.8s; @@ -18,13 +20,9 @@ .video .placeholder { position: absolute; - top: 0; - left: 50%; - width: min(100%, calc((100dvh - var(--header-height) - (2 * var(--space-m))) * 16 / 9)); - aspect-ratio: 16 / 9; + inset: 0; margin: 0; border-radius: var(--radius-m); - transform: translateX(-50%); cursor: pointer; transition: box-shadow 0.2s; } @@ -58,7 +56,7 @@ .video .placeholder:hover::after, .video .placeholder:focus-visible::before, .video .placeholder:focus-visible::after { - transform: translate(-50%, -50%) scale(1.2) ; + transform: translate(-50%, -50%) scale(1.2); } .video[data-source="youtube"] .placeholder::before { diff --git a/blocks/video/video.js b/blocks/video/video.js index f731b4e..06df1ab 100644 --- a/blocks/video/video.js +++ b/blocks/video/video.js @@ -1,13 +1,21 @@ /** - * Builds a YouTube iframe from a youtube.com or youtu.be URL. + * Returns the video provider name for a supported URL. + * @param {string} url - the video page URL to inspect + * @returns {string|null} provider name (e.g. 'youtube') + */ +function detectType(url) { + const { hostname } = new URL(url); + if (hostname === 'youtu.be' || hostname.endsWith('youtube.com')) return 'youtube'; + return null; +} + +/** + * Builds a YouTube iframe. * @param {string} url - the video page or short URL to parse * @returns {HTMLIFrameElement|null} configured embed iframe */ function createYouTubeEmbed(url) { const { hostname, pathname, searchParams } = new URL(url); - const isYouTube = hostname === 'youtu.be' || hostname.endsWith('youtube.com'); - if (!isYouTube) return null; - const id = hostname === 'youtu.be' ? pathname.slice(1) : searchParams.get('v'); if (!id) return null; @@ -20,10 +28,21 @@ function createYouTubeEmbed(url) { } /** - * Builds a thumbnail overlay that removes itself and starts embed playback when activated. + * Builds an embed iframe for the given provider type and URL. + * @param {string} type - provider name returned by detectType + * @param {string} url - the video URL to embed + * @returns {HTMLIFrameElement|null} embed iframe + */ +function createEmbed(type, url) { + if (type === 'youtube') return createYouTubeEmbed(url); + return null; +} + +/** + * Builds a thumbnail overlay. * @param {HTMLElement} block - block element containing the authored thumbnail image - * @param {HTMLIFrameElement} embed - video iframe; receives autoplay=1 on activation - * @returns {HTMLElement|null} the placeholder figure, or null if no image is found in block + * @param {HTMLIFrameElement} embed - video iframe + * @returns {HTMLElement|null} the placeholder figure */ function createPlaceholder(block, embed) { const image = block.querySelector('picture, img'); @@ -37,7 +56,7 @@ function createPlaceholder(block, embed) { function play() { const src = new URL(embed.src); - src.searchParams.set('autoplay', '1'); + src.searchParams.set('autoplay', 1); embed.src = src.href; figure.remove(); } @@ -54,34 +73,40 @@ function createPlaceholder(block, embed) { } /** - * Tries each registered provider against url and returns the matched embed and provider name. - * @param {string} url - the video URL to match against registered providers - * @returns {{ embed: HTMLIFrameElement|null, source: string|null }} embed element and provider name + * Removes the block and its section wrapper from the DOM. + * @param {HTMLElement} block - the block element to remove */ -function createEmbed(url) { - const providers = [['youtube', createYouTubeEmbed]]; - let embed = null; - let source = null; - providers.some(([name, create]) => { - embed = create(url); - if (embed) source = name; - return embed; - }); - return { embed, source }; +function removeBlock(block) { + const wrapper = block.closest('.video-wrapper'); + if (wrapper) wrapper.remove(); } export default function decorate(block) { const link = block.querySelector('a[href]'); - if (!link) { - const wrapper = block.closest('.video-wrapper'); - if (wrapper) wrapper.remove(); - return; - } + if (!link) { removeBlock(block); return; } + + const type = detectType(link.href); + if (!type) { removeBlock(block); return; } + block.dataset.source = type; + + const embed = createEmbed(type, link.href); + if (!embed) { removeBlock(block); return; } - const { embed, source } = createEmbed(link.href); - if (!embed) return; - block.dataset.source = source; const placeholder = createPlaceholder(block, embed); - block.replaceChildren(...[embed, placeholder].filter(Boolean)); + block.textContent = ''; + + if (placeholder) { + block.append(embed, placeholder); + return; + } + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + block.append(embed); + observer.disconnect(); + }); + }, { rootMargin: '0px' }); + observer.observe(block); }