diff --git a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php index 389291c3..c16f2ecb 100644 --- a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php +++ b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php @@ -203,7 +203,13 @@ private function normalizeGeneratedBlock(array $block): array if ( is_array($block['assets'] ?? null) && array() !== $block['assets'] ) { $normalized['assets'] = $block['assets']; } - $scriptDependencies = $this->normalizeScriptDependencies($block['script_dependencies'] ?? null, $normalized['assets'] ?? array()); + // The view module is carried in its own slot and lands as `view.js` + // beside the declared assets, so it can be depended on by that name. + $dependencyAssets = $normalized['assets'] ?? array(); + if ( isset($normalized['view_js']) ) { + $dependencyAssets['view.js'] = $normalized['view_js']; + } + $scriptDependencies = $this->normalizeScriptDependencies($block['script_dependencies'] ?? null, $dependencyAssets); if ( array() !== $scriptDependencies ) { $normalized['script_dependencies'] = $scriptDependencies; } @@ -232,7 +238,10 @@ private function normalizeScriptDependencies(mixed $dependencies, array $assets) $validHandles = array(); foreach ( $handles as $handle ) { - if ( ! is_string($handle) || 1 !== preg_match('/^[A-Za-z0-9_-]+$/', $handle) || isset($validHandles[$handle]) ) { + // A classic script states a handle; a script module states its + // import specifier, which is how `@wordpress/interactivity` + // reaches the generated asset manifest. + if ( ! is_string($handle) || 1 !== preg_match('#^(?:@[a-z0-9][a-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$#', $handle) || isset($validHandles[$handle]) ) { continue; } $validHandles[$handle] = true; diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index 53f7423d..b3c7a0e3 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -285,7 +285,9 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri } if ( 'core/column' === $name ) { - return array( 'opening' => 'blockSupportAttrs($attrs, 'wp-block-column') . '>', 'closing' => '' ); + $width = trim((string) ($attrs['width'] ?? '')); + $columnStyle = trim((string) ($attrs['inlineGeometryStyle'] ?? '') . (preg_match('/^\d+(?:\.\d+)?%$/', $width) ? ';flex-basis:' . $width : ''), ';'); + return array( 'opening' => 'blockSupportAttrs($attrs, 'wp-block-column', $columnStyle) . '>', 'closing' => '' ); } if ( 'core/details' === $name ) { @@ -385,7 +387,9 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri ? $size . ' has-' . $size . '-icon-size' : ''; $labelsClass = ! empty($attrs['showLabels']) ? 'has-visible-labels' : ''; - return array( 'opening' => 'blockSupportAttrs($attrs, trim('wp-block-social-links ' . $labelsClass . ' ' . $sizeClass)) . '>', 'closing' => '' ); + $justification = $this->safeSlug((string) ($attrs['justifyContent'] ?? '')); + $justificationClass = in_array($justification, array( 'left', 'center', 'right', 'space-between' ), true) ? 'is-content-justification-' . $justification : ''; + return array( 'opening' => 'blockSupportAttrs($attrs, trim('wp-block-social-links ' . $labelsClass . ' ' . $sizeClass . ' ' . $justificationClass)) . '>', 'closing' => '' ); } if ( 'core/social-link' === $name ) { diff --git a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php index e0d58f55..3d5c8d4d 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php @@ -482,14 +482,14 @@ public function hasCapturedMediaContent(DOMElement $element): bool public function hasCarouselIdentity(DOMElement $element): bool { - $identity = strtolower(implode(' ', array( + $identity = strtolower((string) preg_replace(array('/([a-z0-9])([A-Z])/', '/([A-Z]+)([A-Z][a-z])/'), array('$1 $2', '$1 $2'), implode(' ', array( $element->tagName, SourceDom::attr($element, 'id'), SourceDom::attr($element, 'class'), SourceDom::attr($element, 'role'), SourceDom::attr($element, 'data-hook'), SourceDom::attr($element, 'data-testid'), - ))); + )))); return 1 === preg_match('/(?:^|[^a-z0-9])(?:carousel|gallery|slider|slideshow)(?:[^a-z0-9]|$)/', $identity); } diff --git a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php index a9892d4d..619ac20a 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php @@ -16,15 +16,26 @@ public function definition(string $namespace): array 'ariaLabel' => array('type' => 'string', 'default' => 'Carousel'), 'itemsPerView' => array('type' => 'number', 'default' => 4), 'wrap' => array('type' => 'boolean', 'default' => true), + 'presentation' => array('type' => 'string', 'default' => 'track'), + 'slideCount' => array('type' => 'number', 'default' => 0), + 'initialSlide' => array('type' => 'number', 'default' => 0), + 'viewportHeight' => array('type' => 'number', 'default' => 0), + 'transitionDuration' => array('type' => 'number', 'default' => 300), + 'autoplayInterval' => array('type' => 'number', 'default' => 0), + 'showDots' => array('type' => 'boolean', 'default' => false), + 'fullBleed' => array('type' => 'boolean', 'default' => false), ); $editor = <<<'JS' ( function( blocks, blockEditor, element ) { var createElement = element.createElement; var InnerBlocks = blockEditor.InnerBlocks; function normalizedItems( value ) { value = Math.round( Number( value ) || 4 ); return Math.min( 6, Math.max( 1, value ) ); } + function normalizedCount( value ) { return Math.max( 0, Math.round( Number( value ) || 0 ) ); } function rootProps( attributes ) { var items = normalizedItems( attributes.itemsPerView ); - return { className: 'blocks-engine-authored-carousel blocks-engine-authored-carousel--items-' + items, role: 'region', 'aria-label': attributes.ariaLabel || 'Carousel', 'aria-roledescription': 'carousel', 'data-wrap': false === attributes.wrap ? 'false' : 'true' }; + var presentation = 'slideshow' === attributes.presentation ? 'slideshow' : 'track'; + var initial = Math.min( Math.max( 0, Math.round( Number( attributes.initialSlide ) || 0 ) ), Math.max( 0, normalizedCount( attributes.slideCount ) - 1 ) ); + return { className: 'blocks-engine-authored-carousel blocks-engine-authored-carousel--items-' + items + ' blocks-engine-authored-carousel--' + presentation + ( attributes.fullBleed ? ' blocks-engine-authored-carousel--full-bleed' : '' ), style: attributes.viewportHeight > 0 ? { '--blocks-engine-carousel-height': Math.round( attributes.viewportHeight ) + 'px', '--blocks-engine-carousel-transition': Math.max( 0, Math.round( Number( attributes.transitionDuration ) || 0 ) ) + 'ms' } : undefined, role: 'region', 'aria-label': attributes.ariaLabel || 'Carousel', 'aria-roledescription': 'carousel', 'data-wrap': false === attributes.wrap ? 'false' : 'true', 'data-wp-interactive': 'blocks-engine/carousel', 'data-wp-context': JSON.stringify( { index: initial, wrap: false !== attributes.wrap, count: 0, visible: items, presentation: presentation, autoplayInterval: Math.max( 0, Math.round( Number( attributes.autoplayInterval ) || 0 ) ), paused: false } ), 'data-wp-init': 'callbacks.init', 'data-wp-on--mouseenter': 'actions.pause', 'data-wp-on--mouseleave': 'actions.resume', 'data-wp-on--focusin': 'actions.pause', 'data-wp-on--focusout': 'actions.resume' }; } blocks.registerBlockType( '__BLOCK_NAME__', { attributes: __ATTRIBUTES__, @@ -33,61 +44,150 @@ function rootProps( attributes ) { return createElement( 'div', { className: 'blocks-engine-authored-carousel-editor' }, createElement( 'strong', null, props.attributes.ariaLabel || 'Carousel' ), createElement( InnerBlocks, { allowedBlocks: [ 'core/image', 'core/group' ], renderAppender: InnerBlocks.ButtonBlockAppender } ) ); }, save: function( props ) { + var dotCount = props.attributes.showDots ? normalizedCount( props.attributes.slideCount ) : 0; + var dots = Array.from( { length: dotCount }, function( _, index ) { return createElement( 'button', { key: index, type: 'button', className: 'blocks-engine-authored-carousel__dot', 'aria-label': 'Show slide ' + ( index + 1 ), 'data-carousel-index': String( index ), 'data-wp-on--click': 'actions.goTo' } ); } ); return createElement( 'div', rootProps( props.attributes ), - createElement( 'button', { type: 'button', className: 'blocks-engine-authored-carousel__previous', 'data-carousel-previous': 'true' }, 'Previous' ), - createElement( 'div', { className: 'blocks-engine-authored-carousel__viewport', tabIndex: 0 }, createElement( 'div', { className: 'blocks-engine-authored-carousel__track' }, createElement( InnerBlocks.Content ) ) ), - createElement( 'button', { type: 'button', className: 'blocks-engine-authored-carousel__next', 'data-carousel-next': 'true' }, 'Next' ), - createElement( 'span', { className: 'blocks-engine-authored-carousel__status', 'aria-live': 'polite', 'aria-atomic': 'true' } ) + createElement( 'button', { type: 'button', className: 'blocks-engine-authored-carousel__previous', 'data-carousel-previous': 'true', 'data-wp-on--click': 'actions.previous', 'data-wp-bind--disabled': 'state.atStart' }, 'Previous' ), + createElement( 'div', { className: 'blocks-engine-authored-carousel__viewport', tabIndex: 0, 'data-wp-on--keydown': 'actions.keydown' }, createElement( 'div', { className: 'blocks-engine-authored-carousel__track' }, createElement( InnerBlocks.Content ) ) ), + createElement( 'button', { type: 'button', className: 'blocks-engine-authored-carousel__next', 'data-carousel-next': 'true', 'data-wp-on--click': 'actions.next', 'data-wp-bind--disabled': 'state.atEnd' }, 'Next' ), + dotCount > 0 ? createElement( 'div', { className: 'blocks-engine-authored-carousel__dots', role: 'group', 'aria-label': 'Choose slide' }, dots ) : null, + createElement( 'span', { className: 'blocks-engine-authored-carousel__status', 'aria-live': 'polite', 'aria-atomic': 'true', 'data-wp-text': 'state.statusText' } ) ); } } ); } )( window.wp.blocks, window.wp.blockEditor, window.wp.element ); JS; + // The Interactivity API is WordPress's own front-end runtime for blocks, + // so the behavior is declared on the markup and the module carries only + // the state the directives read. $view = <<<'JS' -( function() { - function mount( root ) { - if ( root.dataset.carouselMounted ) return; - var viewport = root.querySelector( '.blocks-engine-authored-carousel__viewport' ); - var track = root.querySelector( '.blocks-engine-authored-carousel__track' ); - var previous = root.querySelector( '[data-carousel-previous]' ); - var next = root.querySelector( '[data-carousel-next]' ); - var status = root.querySelector( '.blocks-engine-authored-carousel__status' ); - if ( ! viewport || ! track || ! previous || ! next ) return; - var slides = Array.prototype.slice.call( track.children ); - if ( slides.length < 2 ) return; - var index = 0; - var wraps = 'false' !== root.dataset.wrap; - var reducedMotion = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; - function visibleItems() { - var width = slides[ 0 ].getBoundingClientRect().width; - return width > 0 ? Math.max( 1, Math.min( slides.length, Math.round( viewport.clientWidth / width ) ) ) : 1; - } - function maximumIndex() { return Math.max( 0, slides.length - visibleItems() ); } - function update() { - var maximum = maximumIndex(); - index = Math.min( index, maximum ); - previous.disabled = 0 === maximum || ( ! wraps && 0 === index ); - next.disabled = 0 === maximum || ( ! wraps && index === maximum ); - if ( status ) status.textContent = 'Slide ' + ( index + 1 ) + ' of ' + slides.length; - } - function show( requested ) { - var maximum = maximumIndex(); - index = wraps ? ( requested < 0 ? maximum : requested > maximum ? 0 : requested ) : Math.max( 0, Math.min( maximum, requested ) ); - viewport.scrollTo( { left: slides[ index ].offsetLeft, behavior: reducedMotion ? 'auto' : 'smooth' } ); - update(); +import { store, getContext, getElement, withScope } from '@wordpress/interactivity'; + +const slidesOf = ( ref ) => Array.from( ref.querySelectorAll( '.blocks-engine-authored-carousel__track > *' ) ); + +const rootOf = ( ref ) => ref.closest( '.blocks-engine-authored-carousel' ); + +const visibleCount = ( ref ) => { + const viewport = ref.querySelector( '.blocks-engine-authored-carousel__viewport' ); + const slides = slidesOf( ref ); + if ( ! viewport || 0 === slides.length ) { + return 1; + } + const width = slides[ 0 ].getBoundingClientRect().width; + return width > 0 ? Math.max( 1, Math.min( slides.length, Math.round( viewport.clientWidth / width ) ) ) : 1; +}; + +const maximumIndex = ( context ) => Math.max( 0, context.count - context.visible ); + +const syncSlideshow = ( root, context ) => { + if ( 'slideshow' !== context.presentation ) { + return; + } + slidesOf( root ).forEach( ( slide, index ) => { + const active = index === context.index; + slide.classList.toggle( 'blocks-engine-authored-carousel__slide--active', active ); + slide.setAttribute( 'aria-hidden', active ? 'false' : 'true' ); + slide.toggleAttribute( 'inert', ! active ); + } ); + root.querySelectorAll( '.blocks-engine-authored-carousel__dot' ).forEach( ( dot, index ) => { + dot.classList.toggle( 'blocks-engine-authored-carousel__dot--active', index === context.index ); + if ( index === context.index ) { + dot.setAttribute( 'aria-current', 'true' ); + } else { + dot.removeAttribute( 'aria-current' ); } - previous.addEventListener( 'click', function() { show( index - 1 ); } ); - next.addEventListener( 'click', function() { show( index + 1 ); } ); - viewport.addEventListener( 'keydown', function( event ) { if ( 'ArrowLeft' === event.key || 'ArrowRight' === event.key ) { event.preventDefault(); show( index + ( 'ArrowLeft' === event.key ? -1 : 1 ) ); } } ); - if ( window.ResizeObserver ) new ResizeObserver( update ).observe( viewport ); - root.dataset.carouselMounted = 'true'; - update(); + } ); +}; + +const show = ( requested ) => { + const context = getContext(); + const { ref } = getElement(); + const root = rootOf( ref ); + if ( ! root ) { + return; } - function mountAll() { document.querySelectorAll( '.blocks-engine-authored-carousel' ).forEach( mount ); } - if ( 'loading' === document.readyState ) document.addEventListener( 'DOMContentLoaded', mountAll ); else mountAll(); -} )(); + const maximum = maximumIndex( context ); + context.index = context.wrap + ? ( requested < 0 ? maximum : requested > maximum ? 0 : requested ) + : Math.max( 0, Math.min( maximum, requested ) ); + syncSlideshow( root, context ); + if ( 'slideshow' === context.presentation ) { + return; + } + const viewport = root.querySelector( '.blocks-engine-authored-carousel__viewport' ); + const slide = slidesOf( root )[ context.index ]; + if ( viewport && slide ) { + viewport.scrollTo( { + left: slide.offsetLeft, + behavior: window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 'auto' : 'smooth', + } ); + } +}; + +store( 'blocks-engine/carousel', { + state: { + get atStart() { + const context = getContext(); + return 0 === maximumIndex( context ) || ( ! context.wrap && 0 === context.index ); + }, + get atEnd() { + const context = getContext(); + const maximum = maximumIndex( context ); + return 0 === maximum || ( ! context.wrap && context.index === maximum ); + }, + get statusText() { + const context = getContext(); + return 'Slide ' + ( context.index + 1 ) + ' of ' + context.count; + }, + }, + callbacks: { + init() { + const context = getContext(); + const { ref } = getElement(); + context.count = slidesOf( ref ).length; + context.visible = 'slideshow' === context.presentation ? 1 : visibleCount( ref ); + context.index = Math.min( context.index, maximumIndex( context ) ); + syncSlideshow( ref, context ); + if ( 'slideshow' !== context.presentation || context.autoplayInterval <= 0 || context.count < 2 || window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) { + return; + } + const advance = withScope( () => { + if ( ! context.paused ) { + show( context.index + 1 ); + } + } ); + const timer = window.setInterval( advance, context.autoplayInterval ); + return () => window.clearInterval( timer ); + }, + }, + actions: { + previous() { + show( getContext().index - 1 ); + }, + next() { + show( getContext().index + 1 ); + }, + goTo( event ) { + show( Number( event.currentTarget.dataset.carouselIndex ) || 0 ); + }, + pause() { + getContext().paused = true; + }, + resume() { + getContext().paused = false; + }, + keydown( event ) { + if ( 'ArrowLeft' !== event.key && 'ArrowRight' !== event.key ) { + return; + } + event.preventDefault(); + show( getContext().index + ( 'ArrowLeft' === event.key ? -1 : 1 ) ); + }, + }, +} ); JS; $style = '.blocks-engine-authored-carousel{--blocks-engine-carousel-gap:1rem;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--blocks-engine-carousel-gap);align-items:center;max-width:100%;min-width:0}.blocks-engine-authored-carousel__viewport{min-width:0;overflow:hidden;scroll-behavior:smooth}.blocks-engine-authored-carousel__track{display:grid;grid-auto-flow:column;grid-auto-columns:calc((100% - 3rem)/4);gap:var(--blocks-engine-carousel-gap)}.blocks-engine-authored-carousel--items-1 .blocks-engine-authored-carousel__track{grid-auto-columns:100%}.blocks-engine-authored-carousel--items-2 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 1rem)/2)}.blocks-engine-authored-carousel--items-3 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 2rem)/3)}.blocks-engine-authored-carousel--items-5 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 4rem)/5)}.blocks-engine-authored-carousel--items-6 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 5rem)/6)}.blocks-engine-authored-carousel__track>*{box-sizing:border-box;min-width:0;margin:0}.blocks-engine-authored-carousel__track>.wp-block-image img{display:block;width:100%;aspect-ratio:3/4;object-fit:cover;border-radius:inherit}.blocks-engine-authored-carousel__previous,.blocks-engine-authored-carousel__next{cursor:pointer}.blocks-engine-authored-carousel__previous:disabled,.blocks-engine-authored-carousel__next:disabled{cursor:default;opacity:.45}.blocks-engine-authored-carousel__status{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:900px){.blocks-engine-authored-carousel .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 1rem)/2)}}@media(max-width:600px){.blocks-engine-authored-carousel .blocks-engine-authored-carousel__track{grid-auto-columns:100%}}@media(prefers-reduced-motion:reduce){.blocks-engine-authored-carousel__viewport{scroll-behavior:auto}}'; + $style .= '.blocks-engine-authored-carousel--full-bleed{width:100vw;max-width:none;margin-left:calc(50% - 50vw);margin-right:calc(50% - 50vw)}.blocks-engine-authored-carousel--slideshow{position:relative;display:block;gap:0}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__viewport,.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__track{height:var(--blocks-engine-carousel-height);width:100%}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__track{position:relative;display:block}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__track>*{position:absolute;inset:0;width:100%;height:100%;opacity:0;visibility:hidden;transition:opacity var(--blocks-engine-carousel-transition,300ms) ease,visibility var(--blocks-engine-carousel-transition,300ms) ease}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__track>.blocks-engine-authored-carousel__slide--active{opacity:1;visibility:visible;z-index:1}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__track>.wp-block-image img{width:100%;height:100%;aspect-ratio:auto;object-fit:cover}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__previous,.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__next{position:absolute;top:50%;z-index:3;width:3rem;height:3rem;padding:0;border:0;border-radius:50%;background:rgba(0,0,0,.32);color:#fff;font-size:0;transform:translateY(-50%)}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__previous{left:1rem}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__next{right:1rem}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__previous::before,.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__next::before{display:block;font-size:2rem;line-height:1;content:"\\2039"}.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__next::before{content:"\\203a"}.blocks-engine-authored-carousel__dots{position:absolute;right:0;bottom:1.25rem;left:0;z-index:3;display:flex;justify-content:center;gap:.65rem}.blocks-engine-authored-carousel__dot{width:.75rem;height:.75rem;padding:0;border:1px solid currentColor;border-radius:50%;background:transparent;color:#fff;cursor:pointer}.blocks-engine-authored-carousel__dot--active{background:currentColor}@media(prefers-reduced-motion:reduce){.blocks-engine-authored-carousel--slideshow .blocks-engine-authored-carousel__track>*{transition:none}}'; return array( 'name' => self::LOCAL_NAME, @@ -98,17 +198,20 @@ function mountAll() { document.querySelectorAll( '.blocks-engine-authored-carous 'category' => 'media', 'description' => 'An editable carousel with bounded previous and next navigation.', 'editorScript' => 'file:./index.js', - 'viewScript' => 'file:./view.js', + 'viewScriptModule' => 'file:./view.js', 'style' => 'file:./style.css', 'attributes' => $attributes, - 'supports' => array('html' => false, 'customClassName' => false), + 'supports' => array('html' => false, 'customClassName' => false, 'interactivity' => true), ), 'assets' => array( 'index.js' => str_replace(array('__BLOCK_NAME__', '__ATTRIBUTES__'), array($blockName, json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)), $editor), 'style.css' => $style, ), 'view_js' => $view, - 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element')), + 'script_dependencies' => array( + 'index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element'), + 'view.js' => array('@wordpress/interactivity'), + ), ); } @@ -118,10 +221,35 @@ public function shell(array $attributes): array $label = htmlspecialchars((string) ($attributes['ariaLabel'] ?? 'Carousel'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $items = min(6, max(1, (int) ($attributes['itemsPerView'] ?? 4))); $wrap = false === ($attributes['wrap'] ?? true) ? 'false' : 'true'; + $presentation = 'slideshow' === ($attributes['presentation'] ?? 'track') ? 'slideshow' : 'track'; + $slideCount = max(0, (int) ($attributes['slideCount'] ?? 0)); + $initialSlide = min(max(0, (int) ($attributes['initialSlide'] ?? 0)), max(0, $slideCount - 1)); + $viewportHeight = max(0, (int) ($attributes['viewportHeight'] ?? 0)); + $transitionDuration = max(0, (int) ($attributes['transitionDuration'] ?? 300)); + $autoplayInterval = max(0, (int) ($attributes['autoplayInterval'] ?? 0)); + $fullBleed = true === ($attributes['fullBleed'] ?? false); + $showDots = true === ($attributes['showDots'] ?? false) && 1 < $slideCount; + $classes = 'blocks-engine-authored-carousel blocks-engine-authored-carousel--items-' . $items . ' blocks-engine-authored-carousel--' . $presentation . ($fullBleed ? ' blocks-engine-authored-carousel--full-bleed' : ''); + $styleAttribute = 0 < $viewportHeight ? ' style="--blocks-engine-carousel-height:' . $viewportHeight . 'px;--blocks-engine-carousel-transition:' . $transitionDuration . 'ms"' : ''; + + $context = htmlspecialchars( + (string) json_encode(array('index' => $initialSlide, 'wrap' => 'true' === $wrap, 'count' => 0, 'visible' => $items, 'presentation' => $presentation, 'autoplayInterval' => $autoplayInterval, 'paused' => false), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8' + ); + + $dots = ''; + if ( $showDots ) { + $dots = ''; + } return array( - 'opening' => '', + 'opening' => '
' . $dots . '
', ); } } diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index a1beb1e2..ff8aa05d 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -395,6 +395,8 @@ public static function emittedCoreBlockContracts(): array private const EMPTY_FLEX_ITEM_CLASS = 'blocks-engine-empty-flex-item'; + private const LAYOUT_TABLE_COLUMNS_CLASS = 'blocks-engine-layout-table-columns'; + public const EMPTY_VISUAL_GROUP_CLASS = 'blocks-engine-empty-visual-group'; /** @@ -2026,6 +2028,9 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo // paragraph blocks. Neutralize only those generated inner defaults. $beforeAuthorCssParts[] = ':root :where(.wp-block-group.' . self::CSS_OWNED_LAYOUT_ITEM_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; } + if ( str_contains($serializedBlocks, self::LAYOUT_TABLE_COLUMNS_CLASS) ) { + $afterAuthorCssParts[] = ':root .wp-block-columns.' . self::LAYOUT_TABLE_COLUMNS_CLASS . '{gap:0}'; + } if ( str_contains($serializedBlocks, self::PROPAGATED_LINK_COLOR_CARRIER_CLASS) ) { // The source painted this text; the anchor around it only exists // because a content-wrapping link was pushed into the block. It @@ -2056,6 +2061,10 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo // through that same alignment, for centered and start-aligned // containers alike, while an explicit justification still wins. $afterAuthorCssParts[] = ':root ul.wp-block-social-links:not([class*="is-content-justification-"]){display:inline-flex}'; + $afterAuthorCssParts[] = ':root ul.wp-block-social-links.is-content-justification-left{justify-content:flex-start}' + . ':root ul.wp-block-social-links.is-content-justification-center{justify-content:center}' + . ':root ul.wp-block-social-links.is-content-justification-right{justify-content:flex-end}' + . ':root ul.wp-block-social-links.is-content-justification-space-between{justify-content:space-between}'; } array_push($afterAuthorCssParts, ...$this->generatedSupportStyles()->conditionalAfterAuthorCss($serializedBlocks)); if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) { @@ -2297,7 +2306,7 @@ private function nestedLayoutTableColumnsBlock(DOMElement $table, array &$fallba $columns[] = $column; } - return $this->createBlock('core/columns', $this->styleResolver->presentationAttributes($table), $columns, $table); + return $this->createBlock('core/columns', $this->layoutTableColumnsAttributes($table), $columns, $table); } /** @@ -2314,6 +2323,14 @@ private function layoutTableColumnAttributes(DOMElement $cell): array return $attrs; } + /** @return array */ + private function layoutTableColumnsAttributes(DOMElement $element): array + { + $attrs = $this->styleResolver->presentationAttributes($element); + $attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' ' . self::LAYOUT_TABLE_COLUMNS_CLASS); + return $attrs; + } + /** * @param array> $fallbacks * @return array @@ -2339,7 +2356,7 @@ private function mediaLayoutTableColumnsBlock(DOMElement $table, array &$fallbac ); } if (array() !== $columns) { - $rows[] = $this->createBlock('core/columns', array(), $columns, $row); + $rows[] = $this->createBlock('core/columns', $this->layoutTableColumnsAttributes($row), $columns, $row); } } @@ -6494,12 +6511,24 @@ private function sourceContext(DOMElement $element): array 'role' => $this->attr($element, 'role'), 'id' => $this->attr($element, 'id'), 'class_names' => $this->classNames($element), + 'ancestor_class_names' => $this->ancestorClassNames($element), 'data_attributes' => $this->safeDataAttributes($element), 'structure_signals' => $this->structureSignals($element, array()), 'interactive_attributes' => $this->interactiveAttributes($element), ), static fn (mixed $value): bool => '' !== $value && array() !== $value); } + /** @return list */ + private function ancestorClassNames(DOMElement $element): array + { + $classes = array(); + for ( $ancestor = $element->parentNode; $ancestor instanceof DOMElement && 'body' !== strtolower($ancestor->tagName); $ancestor = $ancestor->parentNode ) { + array_push($classes, ...$this->classNames($ancestor)); + } + + return array_values(array_unique($classes)); + } + private function nearestPreviousHeadingText(DOMElement $element): string { for ( $node = $element->previousSibling; $node instanceof DOMNode; $node = $node->previousSibling ) { @@ -9429,10 +9458,15 @@ private function isImageOnlyAnchor(DOMElement $anchor): bool $imageChildren = 0; foreach ( $anchor->childNodes as $child ) { if ( $child instanceof DOMElement ) { - if ( ! in_array(strtolower($child->tagName), array( 'img', 'picture' ), true) && ! ( $this->imageOnlyCarrierElement($child) instanceof DOMElement ) ) { + if ( in_array(strtolower($child->tagName), array( 'img', 'picture' ), true) || $this->imageOnlyCarrierElement($child) instanceof DOMElement ) { + ++$imageChildren; + continue; + } + // Lightbox links commonly append empty overlay elements beside + // their image. They are decoration, not additional link content. + if ( '' !== trim($child->textContent ?? '') ) { return false; } - ++$imageChildren; continue; } @@ -10046,12 +10080,77 @@ private function authoredCarouselBlock(DOMElement $element): ?array $slides[] = $slide; } + $listIdentity = strtolower(implode(' ', array($list->tagName, $this->attr($list, 'class'), $this->attr($list, 'role')))); + $presentation = 1 === preg_match('/(?:^|[^a-z0-9])slideshow(?:[^a-z0-9]|$)/', $listIdentity) ? 'slideshow' : 'track'; + $initialSlide = 0; + foreach ( $items as $index => $item ) { + if ( '' !== $this->attr($item, 'aria-hidden') || '' !== $this->attr($item, 'data-slideshow-slide') ) { + $presentation = 'slideshow'; + } + if ( 'false' === strtolower(trim($this->attr($item, 'aria-hidden'))) || str_contains(' ' . strtolower($this->attr($item, 'class')) . ' ', ' active ') ) { + $initialSlide = $index; + } + } + + $showDots = false; + foreach ( $element->getElementsByTagName('*') as $candidate ) { + if ( ! $candidate instanceof DOMElement ) { + continue; + } + foreach ( array('data-slide', 'data-slide-index', 'data-carousel-index', 'data-slideshow-item', 'data-uk-slideshow-item') as $attribute ) { + if ( ctype_digit(trim($this->attr($candidate, $attribute))) ) { + $showDots = true; + break 2; + } + } + } + + $durationMilliseconds = static function (string $value): int { + if ( 1 !== preg_match('/^([0-9]+(?:\.[0-9]+)?)(ms|s)$/', strtolower(trim($value)), $matches) ) { + return 0; + } + $milliseconds = (float) $matches[1] * ('s' === $matches[2] ? 1000 : 1); + return (int) round($milliseconds); + }; + $transitionDuration = 0; + $autoplayInterval = 0; + foreach ( $items as $item ) { + $transitionDuration = max($transitionDuration, $durationMilliseconds((string) ($this->styleResolver->cssDeclarations($this->attr($item, 'style'))['animation-duration'] ?? ''))); + foreach ( $item->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement ) { + continue; + } + $autoplayInterval = max($autoplayInterval, $durationMilliseconds((string) ($this->styleResolver->cssDeclarations($this->attr($descendant, 'style'))['animation-duration'] ?? ''))); + } + } + if ( $autoplayInterval <= $transitionDuration ) { + $autoplayInterval = 0; + } + if ( 0 === $transitionDuration ) { + $transitionDuration = 300; + } + + $listHeight = (string) ($this->styleResolver->cssDeclarations($this->attr($list, 'style'))['height'] ?? ''); + $viewportHeight = 1 === preg_match('/^([0-9]+(?:\.[0-9]+)?)px$/', trim($listHeight), $heightMatch) ? (int) round((float) $heightMatch[1]) : 0; + $rootDeclarations = $this->styleResolver->cssDeclarations($this->attr($element, 'style')); + $rootWidth = strtolower((string) preg_replace('/\s+/', '', (string) ($rootDeclarations['width'] ?? ''))); + $fullBleed = ('100vw' === $rootWidth || 1 === preg_match('/^[0-9]+(?:\.[0-9]+)?px$/', $rootWidth)) + && 1 === preg_match('/^-\s*(?:[0-9]+|[0-9]*\.[0-9]+)(?:px|rem|em|%)$/', strtolower(trim((string) ($rootDeclarations['left'] ?? '')))); + $generator = new AuthoredCarouselBlockGenerator(); $this->generatedBlocks()->register(AuthoredCarouselBlockGenerator::class, $generator->definition($this->generatedBlocks()->namespace())); $attributes = array( 'ariaLabel' => trim($this->attr($element, 'aria-label')) ?: 'Carousel', - 'itemsPerView' => min(4, count($slides)), + 'itemsPerView' => 'slideshow' === $presentation ? 1 : min(4, count($slides)), 'wrap' => true, + 'presentation' => $presentation, + 'slideCount' => count($slides), + 'initialSlide' => $initialSlide, + 'viewportHeight' => 'slideshow' === $presentation ? $viewportHeight : 0, + 'transitionDuration' => 'slideshow' === $presentation ? $transitionDuration : 300, + 'autoplayInterval' => 'slideshow' === $presentation ? $autoplayInterval : 0, + 'showDots' => 'slideshow' === $presentation && $showDots, + 'fullBleed' => 'slideshow' === $presentation && $fullBleed, ); $shell = $generator->shell($attributes); $innerContent = array($shell['opening']); diff --git a/php-transformer/src/HtmlToBlocks/NavigationBlockNormalizer.php b/php-transformer/src/HtmlToBlocks/NavigationBlockNormalizer.php index 5f0bce9d..1b5a9e7a 100644 --- a/php-transformer/src/HtmlToBlocks/NavigationBlockNormalizer.php +++ b/php-transformer/src/HtmlToBlocks/NavigationBlockNormalizer.php @@ -210,11 +210,13 @@ private function isMobileDuplicate(array $block, array $sourceProvenance): bool $attributes = is_array($source['source_attributes'] ?? null) ? $source['source_attributes'] : array(); $context = is_array($source['context'] ?? null) ? $source['context'] : array(); $classNames = is_array($context['class_names'] ?? null) ? implode(' ', $context['class_names']) : ''; + $ancestorClassNames = is_array($context['ancestor_class_names'] ?? null) ? implode(' ', $context['ancestor_class_names']) : ''; $haystack = strtolower(trim(implode(' ', array( (string) ($attributes['class'] ?? ''), (string) ($attributes['id'] ?? ''), $classNames, + $ancestorClassNames, )))); return (bool) preg_match('/(?:^|[^a-z0-9])(?:mobile|drawer|offcanvas|overlay|collapsed|hamburger|menu-panel|nav-panel)(?:[^a-z0-9]|$)/', $haystack); diff --git a/php-transformer/src/HtmlToBlocks/Patterns/SocialLinksPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/SocialLinksPattern.php index 6c389022..cde17f1e 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/SocialLinksPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/SocialLinksPattern.php @@ -90,6 +90,12 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter } $attrs = $context->presentationAttributes($element); + for ( $carrier = $element; $carrier instanceof DOMElement && 'body' !== strtolower($carrier->tagName); $carrier = $carrier->parentNode ) { + if ( preg_match('/(?:^|;)\s*text-align\s*:\s*(left|center|right)\b/i', $this->attr($carrier, 'style'), $alignment) ) { + $attrs['justifyContent'] = strtolower($alignment[1]); + break; + } + } if ( $showLabels ) { $attrs['showLabels'] = true; } diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php index 76438a19..88852068 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php @@ -1782,6 +1782,7 @@ private function stripFrozenHiddenState(DOMElement $element, array $declarations array() === $declarations || $this->isDecorativeHiddenElement($element) || $this->isExplicitlyInactiveState($element) + || $this->isHiddenPositionedLayer($element, $declarations) || $this->context->hasRetainedPresentationRuntime($element) ) { return $declarations; @@ -1852,6 +1853,29 @@ private function isExplicitlyInactiveState(DOMElement $element): bool return false; } + /** @param array $declarations */ + private function isHiddenPositionedLayer(DOMElement $element, array $declarations): bool + { + $opacity = CssValueInspector::comparable((string) ($declarations['opacity'] ?? '1')); + $hidden = 'none' === CssValueInspector::comparable((string) ($declarations['display'] ?? '')) + || 'hidden' === CssValueInspector::comparable((string) ($declarations['visibility'] ?? '')) + || (is_numeric($opacity) && 0.0 === (float) $opacity); + if ( ! $hidden ) { + return false; + } + + $resolved = array(); + foreach ( $this->styleRuleCandidates($element, 'static') as $rule ) { + if ( $this->matchesCssSelector($element, $rule['selector']) ) { + $resolved = $this->mergeCssDeclarationMaps($resolved, $rule['declarations']); + } + } + $resolved = $this->mergeCssDeclarationMaps($resolved, $this->cssDeclarations(SourceDom::attr($element, 'style'))); + $resolved = $this->mergeCssDeclarationMaps($resolved, $declarations); + $position = CssValueInspector::comparable((string) ($resolved['position'] ?? '')); + return in_array($position, array( 'absolute', 'fixed' ), true); + } + /** @return list */ public function closedStateRepairCssRules(): array { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index b0bdb5f9..72a7d31d 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -230,8 +230,9 @@ function serialize_blocks(array $blocks): string $assert( 1 === count($carouselDefinitions) && 'authored-carousel' === ($carouselDefinitions[0]['name'] ?? null) - && 'file:./view.js' === ($carouselDefinitions[0]['block_json']['viewScript'] ?? null) - && str_contains((string) ($carouselDefinitions[0]['view_js'] ?? ''), 'data-carousel-next') + && 'file:./view.js' === ($carouselDefinitions[0]['block_json']['viewScriptModule'] ?? null) + && true === ($carouselDefinitions[0]['block_json']['supports']['interactivity'] ?? null) + && str_contains((string) ($carouselDefinitions[0]['view_js'] ?? ''), "store( 'blocks-engine/carousel'") && isset($carouselDefinitions[0]['assets']['style.css']), 'bounded carousel projection carries one generic editor block with scoped frontend behavior' ); @@ -533,12 +534,14 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $nestedLayoutTableLinkedMedia = $nestedLayoutTableResult['blocks'][0]['innerBlocks'][0]['innerBlocks'][0] ?? array(); $assert(TableClassificationPolicy::COMPLEX_NESTED === ($tablePolicy->classify($tableElement($nestedLayoutTableSource))['classification'] ?? null) && $tablePolicy->isNestedLayoutTable($tableElement($nestedLayoutTableSource)), 'nested single-row headerless tables are recognized as layout columns'); $assert('core/columns' === ($nestedLayoutTableResult['blocks'][0]['blockName'] ?? null) && 2 === count($nestedLayoutTableResult['blocks'][0]['innerBlocks'] ?? array()) && 'core/columns' === ($nestedLayoutTableResult['blocks'][0]['innerBlocks'][1]['innerBlocks'][0]['blockName'] ?? null), 'nested layout tables lower to responsive native column blocks'); -$assert('30%' === ($nestedLayoutTableResult['blocks'][0]['innerBlocks'][0]['attrs']['width'] ?? null) && '70%' === ($nestedLayoutTableResult['blocks'][0]['innerBlocks'][1]['attrs']['width'] ?? null), 'layout table cell percentages become core/column width attributes'); +$assert('30%' === ($nestedLayoutTableResult['blocks'][0]['innerBlocks'][0]['attrs']['width'] ?? null) && '70%' === ($nestedLayoutTableResult['blocks'][0]['innerBlocks'][1]['attrs']['width'] ?? null) && str_contains((string) ($nestedLayoutTableResult['serialized_blocks'] ?? ''), 'flex-basis:30%') && str_contains((string) ($nestedLayoutTableResult['serialized_blocks'] ?? ''), 'flex-basis:70%'), 'layout table cell percentages become rendered core/column widths'); $percentLayoutTable = ( new HtmlTransformer() )->transform('
LeftCenterRight
')->toArray(); $percentLayoutTableBlock = $percentLayoutTable['blocks'][0] ?? array(); $assert('core/columns' === ($percentLayoutTableBlock['blockName'] ?? null) && 3 === count($percentLayoutTableBlock['innerBlocks'] ?? array()), 'percent-width layout tables become core/columns'); $assert('18.5%' === ($percentLayoutTableBlock['innerBlocks'][0]['attrs']['width'] ?? null) && '63%' === ($percentLayoutTableBlock['innerBlocks'][1]['attrs']['width'] ?? null) && '18.5%' === ($percentLayoutTableBlock['innerBlocks'][2]['attrs']['width'] ?? null), 'percent-width layout tables preserve cell percentages as column widths'); +$percentLayoutTableCss = implode("\n", array_column($percentLayoutTable['assets'] ?? array(), 'content')); +$assert(str_contains((string) ($percentLayoutTable['serialized_blocks'] ?? ''), 'blocks-engine-layout-table-columns') && str_contains($percentLayoutTableCss, '.wp-block-columns.blocks-engine-layout-table-columns{gap:0}'), 'layout-table columns suppress the core default gap because source cells own their gutters'); $assert('core/table' === (( new HtmlTransformer() )->transform('
AB
')->toArray()['blocks'][0]['blockName'] ?? null), 'headerless tables without cell percentages remain data tables'); $assert(! str_contains($nestedLayoutTableMarkup, '