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 = '';
+ for ( $index = 0; $index < $slideCount; ++$index ) {
+ $dots .= '';
+ }
+ $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('')->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('')->toArray()['blocks'][0]['blockName'] ?? null), 'headerless tables without cell percentages remain data tables');
$assert(! str_contains($nestedLayoutTableMarkup, '