diff --git a/Gruntfile.js b/Gruntfile.js index 64651fece8299..7bbf085607515 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -423,6 +423,7 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-includes/js/wp-pointer.js' ]: [ './src/js/_enqueues/lib/pointer.js' ], [ WORKING_DIR + 'wp-includes/js/wp-sanitize.js' ]: [ './src/js/_enqueues/wp/sanitize.js' ], [ WORKING_DIR + 'wp-includes/js/wp-util.js' ]: [ './src/js/_enqueues/wp/util.js' ], + [ WORKING_DIR + 'wp-includes/js/wp-view-transitions.js' ]: [ './src/js/_enqueues/wp/view-transitions.js' ], [ WORKING_DIR + 'wp-includes/js/wpdialog.js' ]: [ './src/js/_enqueues/lib/dialog.js' ], [ WORKING_DIR + 'wp-includes/js/wplink.js' ]: [ './src/js/_enqueues/lib/link.js' ], [ WORKING_DIR + 'wp-includes/js/zxcvbn-async.js' ]: [ './src/js/_enqueues/lib/zxcvbn-async.js' ] @@ -1005,6 +1006,7 @@ module.exports = function(grunt) { 'src/wp-includes/js/wp-pointer.js': 'src/js/_enqueues/lib/pointer.js', 'src/wp-includes/js/wp-sanitize.js': 'src/js/_enqueues/wp/sanitize.js', 'src/wp-includes/js/wp-util.js': 'src/js/_enqueues/wp/util.js', + 'src/wp-includes/js/wp-view-transitions.js': 'src/js/_enqueues/wp/view-transitions.js', 'src/wp-includes/js/wpdialog.js': 'src/js/_enqueues/lib/dialog.js', 'src/wp-includes/js/wplink.js': 'src/js/_enqueues/lib/link.js', 'src/wp-includes/js/zxcvbn-async.js': 'src/js/_enqueues/lib/zxcvbn-async.js', diff --git a/src/js/_enqueues/wp/view-transitions.js b/src/js/_enqueues/wp/view-transitions.js new file mode 100644 index 0000000000000..2ecb1603a1a8e --- /dev/null +++ b/src/js/_enqueues/wp/view-transitions.js @@ -0,0 +1,310 @@ +/** + * @output wp-includes/js/wp-view-transitions.js + */ + +window.wp = window.wp || {}; +window.wp.viewTransitions = {}; + +/** + * Initializes view transitions for the current URL. + * + * @param {object} config The view transitions configuration. + * @param {string} config.postSelector General selector for post elements in the DOM. + * @param {object} config.globalTransitionNames Map of selectors for global elements (queried relative to 'body') + * and their view transition names. + * @param {object} config.postTransitionNames Map of selectors for post elements (queried relative to an element + * identified by config.postSelector) and their view transition names. + * @param {boolean} config.chronologicalSlideInOut Whether slide in/out animation for chronological URL relationship + * (date- or pagination-based) should be enabled. + */ +window.wp.viewTransitions.init = ( config ) => { + if ( ! window.navigation || ! ( 'CSSViewTransitionRule' in window ) ) { + window.console.warn( 'View transitions not loaded as the browser is lacking support.' ); + return; + } + + /** + * Gets all view transition entries relevant for a view transition. + * + * @param {string} transitionType View transition type (e.g. 'default', 'chronological-forwards', 'chronological-backwards'). + * @param {Element} bodyElement The body element. + * @param {Element|null} articleElement The post element relevant for the view transition, if any. + * @return {Array[]} View transition entries with each one containing the element and its view transition name. + */ + const getViewTransitionEntries = ( transitionType, bodyElement, articleElement ) => { + const globalEntries = config.animations[ transitionType ].useGlobalTransitionNames ? + Object.entries( config.globalTransitionNames || {} ).map( ( [ selector, name ] ) => { + const element = bodyElement.querySelector( selector ); + return [ element, name ]; + } ) : []; + + const postEntries = config.animations[ transitionType ].usePostTransitionNames && articleElement ? + Object.entries( config.postTransitionNames || {} ).map( ( [ selector, name ] ) => { + const element = articleElement.querySelector( selector ); + return [ element, name ]; + } ) : []; + + return [ + ...globalEntries, + ...postEntries, + ]; + }; + + /** + * Temporarily sets view transition names for the given entries until the view transition has been completed. + * + * @param {Array[]} entries View transition entries as received from `getViewTransitionEntries()`. + * @param {Promise} vtPromise Promise that resolves after the view transition has been completed. + * @return {Promise} Promise that resolves after the view transition names were reset. + */ + const setTemporaryViewTransitionNames = async ( entries, vtPromise ) => { + for ( const [ element, name ] of entries ) { + if ( ! element ) { + continue; + } + element.style.viewTransitionName = name; + } + + await vtPromise; + + for ( const [ element ] of entries ) { + if ( ! element ) { + continue; + } + element.style.viewTransitionName = ''; + } + }; + + /** + * Appends a selector to another selector. + * + * This supports selectors which technically include multiple selectors (separated by comma). + * + * @param {string} selectors Main selector. + * @param {string} append Selector to append to the main selector. + * @return {string} Combined selector. + */ + const appendSelectors = ( selectors, append ) => { + return selectors.split( ',' ).map( subselector => subselector.trim() + ' ' + append ).join( ',' ); + }; + + /** + * Gets a post element (the first on the page, in case there are multiple). + * + * @return {Element|null} Post element, or null if none is found. + */ + const getArticle = () => { + if ( ! config.postSelector ) { + return null; + } + return document.querySelector( config.postSelector ); + }; + + /** + * Gets the post element for a specific post URL. + * + * @param {string} url Post URL (permalink) to find post element. + * @return {Element|null} Post element, or null if none is found. + */ + const getArticleForUrl = ( url ) => { + if ( ! config.postSelector ) { + return null; + } + const postLinkSelector = appendSelectors( config.postSelector, 'a[href="' + url + '"]' ); + const articleLink = document.querySelector( postLinkSelector ); + if ( ! articleLink ) { + return null; + } + return articleLink.closest( config.postSelector ); + }; + + /** + * Determines the view transition type to use, given an old and new navigation history entry. + * + * @param {NavigationHistoryEntry} oldEntry Navigation history entry for the URL navigated from. + * @param {NavigationHistoryEntry} newEntry Navigation history entry for the URL navigated to. + * @return {string} View transition type (e.g. 'default', 'chronological-forwards', 'chronological-backwards'). + */ + const determineTransitionType = ( oldEntry, newEntry ) => { + if ( ! oldEntry || ! newEntry ) { + return 'default'; + } + + // Use 'default' transition type if all other transition types are disabled. + if ( + ! config.animations['chronological-forwards'] && + ! config.animations['chronological-backwards'] && + ! config.animations['pagination-forwards'] && + ! config.animations['pagination-backwards'] + ) { + return 'default'; + } + + const oldURL = new URL( oldEntry.url ); + const newURL = new URL( newEntry.url ); + + const oldPathname = oldURL.pathname; + const newPathname = newURL.pathname; + + if ( oldPathname === newPathname ) { + return 'default'; + } + + let oldPageMatches = false; + let newPageMatches = false; + let prefix = ''; + + // If enabled, check if the URLs are for a chronologically paginated archive. + if ( config.animations['chronological-forwards'] || config.animations['chronological-backwards'] ) { + oldPageMatches = oldPathname.match( /\/page\/(\d+)\/?$/ ); + newPageMatches = newPathname.match( /\/page\/(\d+)\/?$/ ); + prefix = 'chronological-'; + } + + // If not, check if the URLs are for a multi-page post. + if ( ! oldPageMatches && ! newPageMatches && ( config.animations['pagination-forwards'] || config.animations['pagination-backwards'] ) ) { + oldPageMatches = oldPathname.match( /\/(\d+)\/?$/ ); + newPageMatches = newPathname.match( /\/(\d+)\/?$/ ); + prefix = 'pagination-'; + } + + // If there is a match on at least one of the URLs, compare whether their roots before the page segment match. + if ( oldPageMatches || newPageMatches ) { + const oldPageBase = oldPageMatches ? oldPathname.substring( 0, oldPathname.length - oldPageMatches[ 0 ].length ) : oldPathname.replace( /\/$/, '' ); + const newPageBase = newPageMatches ? newPathname.substring( 0, newPathname.length - newPageMatches[ 0 ].length ) : newPathname.replace( /\/$/, '' ); + if ( oldPageBase === newPageBase ) { // They belong to the same archive or post. + // Return the appropriate transition type, or 'default' if no particular animation is specified. + if ( oldPageMatches && newPageMatches ) { + if ( Number( oldPageMatches[ 1 ] ) < Number( newPageMatches[ 1 ] ) ) { + return config.animations[ `${ prefix }forwards` ] ? `${ prefix }forwards` : 'default'; + } + return config.animations[ `${ prefix }backwards` ] ? `${ prefix }backwards` : 'default'; + } + if ( newPageMatches && Number( newPageMatches[ 1 ] ) > 1 ) { + return config.animations[ `${ prefix }forwards` ] ? `${ prefix }forwards` : 'default'; + } + if ( oldPageMatches && Number( oldPageMatches[ 1 ] ) > 1 ) { + return config.animations[ `${ prefix }backwards` ] ? `${ prefix }backwards` : 'default'; + } + } + } + + // If enabled, check if the URLs are for content labelled by date (e.g. navigation to previous/next post). + if ( config.animations['chronological-forwards'] || config.animations['chronological-backwards'] ) { + const oldDateMatches = oldPathname.match( /\/(\d{4})\/(\d{2})\/(\d{2})\/[^\/]+\/?$/ ); + const newDateMatches = newPathname.match( /\/(\d{4})\/(\d{2})\/(\d{2})\/[^\/]+\/?$/ ); + if ( oldDateMatches && newDateMatches ) { + const oldPageBase = oldPathname.substring( 0, oldPathname.length - oldDateMatches[ 0 ].length ); + const newPageBase = newPathname.substring( 0, newPathname.length - newDateMatches[ 0 ].length ); + if ( oldPageBase === newPageBase ) { // They belong to the same hierarchy. + const oldDate = new Date( parseInt( oldDateMatches[ 1 ] ), parseInt( oldDateMatches[ 2 ] ) - 1, parseInt( oldDateMatches[ 3 ] ) ); + const newDate = new Date( parseInt( newDateMatches[ 1 ] ), parseInt( newDateMatches[ 2 ] ) - 1, parseInt( newDateMatches[ 3 ] ) ); + if ( oldDate < newDate ) { + return config.animations['chronological-forwards'] ? 'chronological-forwards' : 'default'; + } + if ( oldDate > newDate ) { + return config.animations['chronological-backwards'] ? 'chronological-backwards' : 'default'; + } + } + } + } + + return 'default'; + }; + + /** + * Gets the view transition name for the element that receives a slide animation based on the transition type determined, if any. + * + * @param {string} transitionType View transition type as received from `determineTransitionType()`. + * @return {string|null} View transition name, or null if none is relevant for the transition type. + */ + const getViewTransitionNameForSlideAnimation = ( transitionType ) => { + if ( config.animations[ transitionType ] && config.animations[ transitionType ].targetName !== '*' ) { + return config.animations[ transitionType ].targetName; + } + return null; + }; + + /** + * Customizes view transition behavior on the URL that is being navigated from. + * + * @param {PageSwapEvent} event Event fired as the previous URL is about to unload. + */ + window.addEventListener( 'pageswap', ( event ) => { + if ( event.viewTransition ) { + const transitionType = determineTransitionType( event.activation.from, event.activation.entry ); + event.viewTransition.types.add( transitionType ); + + let viewTransitionEntries; + if ( document.body.classList.contains( 'single' ) ) { + viewTransitionEntries = getViewTransitionEntries( + transitionType, + document.body, + getArticle() + ); + } else if ( document.body.classList.contains( 'home' ) || document.body.classList.contains( 'archive' ) ) { + viewTransitionEntries = getViewTransitionEntries( + transitionType, + document.body, + getArticleForUrl( event.activation.entry.url ) + ); + } + if ( viewTransitionEntries ) { + setTemporaryViewTransitionNames( viewTransitionEntries, event.viewTransition.finished ); + + const slideViewTransitionName = getViewTransitionNameForSlideAnimation( transitionType ); + if ( slideViewTransitionName ) { + // Consider a scroll offset if defined (e.g. due to fixed navigation bars being in the way). + const scrollYOffset = document.documentElement.style.getPropertyValue( '--wp-scroll-y-offset' ); + const currentScrollY = window.scrollY - ( scrollYOffset ? parseInt( scrollYOffset, 10 ) : 0 ); + sessionStorage.setItem( 'wpViewTransitionsOldScrollY', currentScrollY ); + } + } + } + } ); + + /** + * Customizes view transition behavior on the URL that is being navigated to. + * + * @param {PageRevealEvent} event Event fired as the new URL being navigated to is loaded. + */ + window.addEventListener( 'pagereveal', ( event ) => { + if ( event.viewTransition ) { + const transitionType = determineTransitionType( window.navigation.activation.from, window.navigation.activation.entry ); + event.viewTransition.types.add( transitionType ); + + let viewTransitionEntries; + if ( document.body.classList.contains( 'single' ) ) { + viewTransitionEntries = getViewTransitionEntries( + transitionType, + document.body, + getArticle() + ); + } else if ( document.body.classList.contains( 'home' ) || document.body.classList.contains( 'archive' ) ) { + viewTransitionEntries = getViewTransitionEntries( + transitionType, + document.body, + window.navigation.activation.from ? getArticleForUrl( window.navigation.activation.from.url ) : null + ); + } + if ( viewTransitionEntries ) { + setTemporaryViewTransitionNames( viewTransitionEntries, event.viewTransition.ready ); + + const slideViewTransitionName = getViewTransitionNameForSlideAnimation( transitionType ); + if ( slideViewTransitionName ) { + const oldScrollY = sessionStorage.getItem( 'wpViewTransitionsOldScrollY' ); + if ( oldScrollY !== null ) { + // Align vertical scroll position. + if ( oldScrollY ) { + window.scrollTo( 0, parseInt( oldScrollY, 10 ) ); + } + sessionStorage.removeItem( 'wpViewTransitionsOldScrollY' ); + } else { + // Skip view transition to avoid an odd diagonal slide. + event.viewTransition.skipTransition(); + } + } + } + } + } ); +}; diff --git a/src/wp-content/themes/twentyeleven/functions.php b/src/wp-content/themes/twentyeleven/functions.php index 1891e6d663997..aa9df442f120b 100644 --- a/src/wp-content/themes/twentyeleven/functions.php +++ b/src/wp-content/themes/twentyeleven/functions.php @@ -101,6 +101,9 @@ function twentyeleven_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom color scheme. add_theme_support( 'editor-color-palette', diff --git a/src/wp-content/themes/twentyfifteen/functions.php b/src/wp-content/themes/twentyfifteen/functions.php index 0e4e0a7963aef..f8b350c084139 100644 --- a/src/wp-content/themes/twentyfifteen/functions.php +++ b/src/wp-content/themes/twentyfifteen/functions.php @@ -197,6 +197,9 @@ function twentyfifteen_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom color scheme. add_theme_support( 'editor-color-palette', diff --git a/src/wp-content/themes/twentyfourteen/functions.php b/src/wp-content/themes/twentyfourteen/functions.php index f5e87e236d70a..aa2bc639e0229 100644 --- a/src/wp-content/themes/twentyfourteen/functions.php +++ b/src/wp-content/themes/twentyfourteen/functions.php @@ -94,6 +94,9 @@ function twentyfourteen_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom color scheme. add_theme_support( 'editor-color-palette', diff --git a/src/wp-content/themes/twentynineteen/functions.php b/src/wp-content/themes/twentynineteen/functions.php index 41a798bfbfb22..4c81c8fd86adc 100644 --- a/src/wp-content/themes/twentynineteen/functions.php +++ b/src/wp-content/themes/twentynineteen/functions.php @@ -169,6 +169,9 @@ function twentynineteen_setup() { // Add support for responsive embedded content. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom line height. add_theme_support( 'custom-line-height' ); } diff --git a/src/wp-content/themes/twentyseventeen/functions.php b/src/wp-content/themes/twentyseventeen/functions.php index 2a4dae44709cf..7bb6c071e919f 100644 --- a/src/wp-content/themes/twentyseventeen/functions.php +++ b/src/wp-content/themes/twentyseventeen/functions.php @@ -133,6 +133,9 @@ function twentyseventeen_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Define and register starter content to showcase the theme on new sites. $starter_content = array( 'widgets' => array( diff --git a/src/wp-content/themes/twentysixteen/functions.php b/src/wp-content/themes/twentysixteen/functions.php index f9ae9f6ef2549..c2bdd71271c8f 100644 --- a/src/wp-content/themes/twentysixteen/functions.php +++ b/src/wp-content/themes/twentysixteen/functions.php @@ -160,6 +160,9 @@ function twentysixteen_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom color scheme. add_theme_support( 'editor-color-palette', diff --git a/src/wp-content/themes/twentythirteen/functions.php b/src/wp-content/themes/twentythirteen/functions.php index 63231d2a88ee5..0c102e89413a3 100644 --- a/src/wp-content/themes/twentythirteen/functions.php +++ b/src/wp-content/themes/twentythirteen/functions.php @@ -113,6 +113,9 @@ function twentythirteen_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom color scheme. add_theme_support( 'editor-color-palette', diff --git a/src/wp-content/themes/twentytwelve/functions.php b/src/wp-content/themes/twentytwelve/functions.php index b53a2d8090a52..12659e37c0449 100644 --- a/src/wp-content/themes/twentytwelve/functions.php +++ b/src/wp-content/themes/twentytwelve/functions.php @@ -73,6 +73,9 @@ function twentytwelve_setup() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom color scheme. add_theme_support( 'editor-color-palette', diff --git a/src/wp-content/themes/twentytwenty/functions.php b/src/wp-content/themes/twentytwenty/functions.php index 96945243fa574..fd3995b4251d8 100644 --- a/src/wp-content/themes/twentytwenty/functions.php +++ b/src/wp-content/themes/twentytwenty/functions.php @@ -117,6 +117,9 @@ function twentytwenty_theme_support() { // Add support for responsive embeds. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + /* * Adds starter content to highlight the theme on fresh sites. * This is done conditionally to avoid loading the starter content on every diff --git a/src/wp-content/themes/twentytwentyone/functions.php b/src/wp-content/themes/twentytwentyone/functions.php index f163e394df734..5342c67d29364 100644 --- a/src/wp-content/themes/twentytwentyone/functions.php +++ b/src/wp-content/themes/twentytwentyone/functions.php @@ -319,6 +319,9 @@ function twenty_twenty_one_setup() { // Add support for responsive embedded content. add_theme_support( 'responsive-embeds' ); + // Add support for view transitions. + add_theme_support( 'view-transitions' ); + // Add support for custom line height controls. add_theme_support( 'custom-line-height' ); diff --git a/src/wp-includes/class-wp-view-transition-animation-registry.php b/src/wp-includes/class-wp-view-transition-animation-registry.php new file mode 100644 index 0000000000000..b497893687a06 --- /dev/null +++ b/src/wp-includes/class-wp-view-transition-animation-registry.php @@ -0,0 +1,152 @@ + + */ + private $registered_animations = array(); + + /** + * Map of aliases to their animation slugs. + * + * Includes the animation slug itself to avoid unnecessary conditionals. + * + * @since 6.9.0 + * @var array + */ + private $alias_map = array(); + + /** + * Registers a view transition animation. + * + * @since 6.9.0 + * + * @param string $slug Unique animation slug. + * @param array $config Animation configuration. See + * {@see WP_View_Transition_Animation::__construct()} for possible + * values. + * @param array $default_args Optional. Default animation arguments. Default empty array. + * @return bool True on success, false on failure. + */ + public function register_animation( string $slug, array $config, array $default_args = array() ): bool { + // Check slug conflict. + if ( isset( $this->alias_map[ $slug ] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: duplicate slug */ + __( 'The animation slug "%s" conflicts with an existing slug or alias.' ), + esc_html( $slug ) + ), + '6.9.0' + ); + return false; + } + + try { + $animation = new WP_View_Transition_Animation( $slug, $config, $default_args ); + } catch ( InvalidArgumentException $e ) { + _doing_it_wrong( + __METHOD__, + $e->getMessage(), + '6.9.0' + ); + return false; + } + + // Check alias conflicts. + $aliases = $animation->get_aliases(); + foreach ( $aliases as $alias ) { + if ( isset( $this->alias_map[ $alias ] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: duplicate alias */ + __( 'The animation alias "%s" conflicts with an existing slug or alias.' ), + esc_html( $alias ) + ), + '6.9.0' + ); + return false; + } + } + + $this->registered_animations[ $slug ] = $animation; + $this->alias_map[ $slug ] = $slug; + foreach ( $aliases as $alias ) { + $this->alias_map[ $alias ] = $slug; + } + + return true; + } + + /** + * Gets the animation stylesheet for the given alias, as inline CSS. + * + * @since 6.9.0 + * + * @param string $alias Slug or alias to reference the animation with. May be used to alter the + * animation's behavior. + * @param array $args Optional. Animation arguments. Default is the animation's default arguments. + * @return string Animation stylesheet, as inline CSS, or empty string if none. + */ + public function get_animation_stylesheet( string $alias, array $args = array() ): string { + if ( ! isset( $this->alias_map[ $alias ] ) ) { + return ''; + } + + return $this->registered_animations[ $this->alias_map[ $alias ] ]->get_stylesheet( $alias, $args ); + } + + /** + * Returns whether to apply the global view transition names for the given animation alias. + * + * @since 6.9.0 + * + * @param string $alias Slug or alias to reference the animation with. May be used to alter the + * animation's behavior. + * @param array $args Optional. Animation arguments. Default is the animation's default arguments. + * @return bool True if the global view transition names should be applied, false otherwise. + */ + public function use_animation_global_transition_names( string $alias, array $args = array() ): bool { + if ( ! isset( $this->alias_map[ $alias ] ) ) { + return true; + } + + return $this->registered_animations[ $this->alias_map[ $alias ] ]->use_global_transition_names( $alias, $args ); + } + + /** + * Returns whether to apply the post specific view transition names for the given animation alias. + * + * @since 6.9.0 + * + * @param string $alias Slug or alias to reference the animation with. May be used to alter the + * animation's behavior. + * @param array $args Optional. Animation arguments. Default is the animation's default arguments. + * @return bool True if the post specific view transition names should be applied, false otherwise. + */ + public function use_animation_post_transition_names( string $alias, array $args = array() ): bool { + if ( ! isset( $this->alias_map[ $alias ] ) ) { + return true; + } + + return $this->registered_animations[ $this->alias_map[ $alias ] ]->use_post_transition_names( $alias, $args ); + } +} diff --git a/src/wp-includes/class-wp-view-transition-animation.php b/src/wp-includes/class-wp-view-transition-animation.php new file mode 100644 index 0000000000000..742205739e4d1 --- /dev/null +++ b/src/wp-includes/class-wp-view-transition-animation.php @@ -0,0 +1,278 @@ + + */ + private $default_args = array(); + + /** + * Constructor. + * + * @since 6.9.0 + * + * @param string $slug Unique animation slug. + * @param array $config { + * Animation configuration. + * + * @type string[] $aliases Unique aliases for the animation, if any. Default empty + * array. + * @type bool $use_stylesheet Whether the animation uses a stylesheet. Default false. + * @type bool|callable $use_global_transition_names Whether to apply the global view transition names while + * using this animation. Alternatively to a concrete value, a + * callback can be specified to determine it dynamically. + * Default true. + * @type bool|callable $use_post_transition_names Whether to apply the post specific view transition names + * while using this animation. Alternatively to a concrete + * value, acallback can be specified to determine it + * dynamically. Default true. + * @type callable|null $get_stylesheet_callback Callback to get the stylesheet for the animation, as + * inline CSS. This can be used if the animation CSS requires + * further preparation other than simply loading its + * stylesheet from the animation's corresponding CSS file. + * Default null. + * } + * @param array $default_args Optional. Default animation arguments. Default empty array. + * + * @throws InvalidArgumentException Thrown if the slug or an alias is invalid. + */ + public function __construct( string $slug, array $config, array $default_args = array() ) { + if ( ! $this->is_valid_slug( $slug ) ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: invalid slug */ + __( 'The animation slug "%s" is invalid.' ), + esc_html( $slug ) + ) + ); + } + + $this->slug = $slug; + + $this->apply_config( $config ); + + $this->default_args = $default_args; + } + + /** + * Gets the unique animation slug. + * + * @since 6.9.0 + * + * @return string Unique animation slug. + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Gets the unique aliases for the animation, if any. + * + * @since 6.9.0 + * + * @return string[] Unique aliases for the animation, or empty array if none. + */ + public function get_aliases(): array { + return $this->aliases; + } + + /** + * Gets the animation stylesheet, as inline CSS. + * + * @since 6.9.0 + * + * @param string $alias Optional. Slug or alias to reference the animation with. May be used to alter + * the animation's behavior. Default is the animation's slug. + * @param array $args Optional. Animation arguments. Default is the animation's default arguments. + * @return string Animation stylesheet, as inline CSS, or empty string if none. + */ + public function get_stylesheet( string $alias = '', array $args = array() ): string { + if ( $this->use_stylesheet ) { + $suffix = wp_scripts_get_suffix(); + $css = file_get_contents( ABSPATH . WPINC . "/css/view-transitions-animation-{$this->slug}{$suffix}.css" ); + } + if ( is_callable( $this->get_stylesheet_callback ) ) { + if ( ! $alias ) { + $alias = $this->slug; + } + $args = wp_parse_args( $args, $this->default_args ); + return (string) call_user_func_array( + $this->get_stylesheet_callback, + isset( $css ) ? array( $css, $alias, $args ) : array( $alias, $args ) + ); + } + return ''; + } + + /** + * Returns whether to apply the global view transition names while using this animation. + * + * @since 6.9.0 + * + * @param string $alias Optional. Slug or alias to reference the animation with. May be used to alter + * the animation's behavior. Default is the animation's slug. + * @param array $args Optional. Animation arguments. Default is the animation's default arguments. + * @return bool True if the global view transition names should be applied, false otherwise. + */ + public function use_global_transition_names( string $alias = '', array $args = array() ): bool { + if ( is_bool( $this->use_global_transition_names ) ) { + return $this->use_global_transition_names; + } + if ( ! $alias ) { + $alias = $this->slug; + } + $args = wp_parse_args( $args, $this->default_args ); + return call_user_func( $this->use_global_transition_names, $alias, $args ); + } + + /** + * Returns whether to apply the post specific view transition names while using this animation. + * + * @since 6.9.0 + * + * @param string $alias Optional. Slug or alias to reference the animation with. May be used to alter + * the animation's behavior. Default is the animation's slug. + * @param array $args Optional. Animation arguments. Default is the animation's default arguments. + * @return bool True if the post specific view transition names should be applied, false otherwise. + */ + public function use_post_transition_names( string $alias = '', array $args = array() ): bool { + if ( is_bool( $this->use_post_transition_names ) ) { + return $this->use_post_transition_names; + } + if ( ! $alias ) { + $alias = $this->slug; + } + $args = wp_parse_args( $args, $this->default_args ); + return call_user_func( $this->use_post_transition_names, $alias, $args ); + } + + /** + * Applies the given configuration to the class properties. + * + * @since 6.9.0 + * + * @param array $config Animation configuration. See + * {@see WP_View_Transition_Animation::__construct()} for possible values. + */ + private function apply_config( array $config ): void { + if ( isset( $config['aliases'] ) ) { + $this->aliases = (array) $config['aliases']; + foreach ( $this->aliases as $alias ) { + if ( ! $this->is_valid_slug( $alias ) ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: invalid alias */ + __( 'The animation alias "%s" is invalid.' ), + esc_html( $alias ) + ) + ); + } + } + } + if ( isset( $config['use_stylesheet'] ) ) { + $this->use_stylesheet = (bool) $config['use_stylesheet']; + } + if ( isset( $config['use_global_transition_names'] ) ) { + $this->use_global_transition_names = is_callable( $config['use_global_transition_names'] ) ? + $config['use_global_transition_names'] : + (bool) $config['use_global_transition_names']; + } + if ( isset( $config['use_post_transition_names'] ) ) { + $this->use_post_transition_names = is_callable( $config['use_post_transition_names'] ) ? + $config['use_post_transition_names'] : + (bool) $config['use_post_transition_names']; + } + if ( isset( $config['get_stylesheet_callback'] ) && is_callable( $config['get_stylesheet_callback'] ) ) { + $this->get_stylesheet_callback = $config['get_stylesheet_callback']; + } + } + + /** + * Checks whether the given slug (or alias) is valid. + * + * @since 6.9.0 + * + * @param string $slug Animation slug or alias. + * @return bool True if the ID is valid, false otherwise. + */ + private function is_valid_slug( string $slug ): bool { + return (bool) preg_match( '/^[a-z][a-z0-9_-]+$/', $slug ); + } +} diff --git a/src/wp-includes/css/view-transitions-animation-slide.css b/src/wp-includes/css/view-transitions-animation-slide.css new file mode 100644 index 0000000000000..ce1f85b69aa2b --- /dev/null +++ b/src/wp-includes/css/view-transitions-animation-slide.css @@ -0,0 +1,35 @@ +@property --wp-view-transition-animation-slide-horizontal-offset { + syntax: ""; + initial-value: 1; + inherits: false; +} + +@property --wp-view-transition-animation-slide-vertical-offset { + syntax: ""; + initial-value: 0; + inherits: false; +} + +@keyframes wp-view-transition-animation-slide-old { + to { + translate: calc(100vw * var(--wp-view-transition-animation-slide-horizontal-offset) * -1) calc(100vw * var(--wp-view-transition-animation-slide-vertical-offset) * -1); + } +} + +@keyframes wp-view-transition-animation-slide-new { + from { + translate: calc(100vw * var(--wp-view-transition-animation-slide-horizontal-offset)) calc(100vw * var(--wp-view-transition-animation-slide-vertical-offset)); + } +} + +::view-transition-group(*) { + animation-duration: 1s; +} + +::view-transition-old(*) { + animation-name: wp-view-transition-animation-slide-old; +} + +::view-transition-new(*) { + animation-name: wp-view-transition-animation-slide-new; +} diff --git a/src/wp-includes/css/view-transitions-animation-swipe.css b/src/wp-includes/css/view-transitions-animation-swipe.css new file mode 100644 index 0000000000000..ad8ac5a54ba4b --- /dev/null +++ b/src/wp-includes/css/view-transitions-animation-swipe.css @@ -0,0 +1,37 @@ +@property --wp-view-transition-animation-swipe-horizontal-offset { + syntax: ""; + initial-value: 1; + inherits: false; +} + +@property --wp-view-transition-animation-swipe-vertical-offset { + syntax: ""; + initial-value: 0; + inherits: false; +} + +@keyframes wp-view-transition-animation-swipe-old { + to { + opacity: 0; + translate: calc(100vw * var(--wp-view-transition-animation-swipe-horizontal-offset) * -1) calc(100vw * var(--wp-view-transition-animation-swipe-vertical-offset) * -1); + } +} + +@keyframes wp-view-transition-animation-swipe-new { + from { + opacity: 0; + translate: calc(100vw * var(--wp-view-transition-animation-swipe-horizontal-offset)) calc(100vw * var(--wp-view-transition-animation-swipe-vertical-offset)); + } +} + +::view-transition-group(*) { + animation-duration: 1s; +} + +::view-transition-old(*) { + animation-name: wp-view-transition-animation-swipe-old; +} + +::view-transition-new(*) { + animation-name: wp-view-transition-animation-swipe-new; +} diff --git a/src/wp-includes/css/view-transitions-animation-wipe.css b/src/wp-includes/css/view-transitions-animation-wipe.css new file mode 100644 index 0000000000000..0c12c1dc777a5 --- /dev/null +++ b/src/wp-includes/css/view-transitions-animation-wipe.css @@ -0,0 +1,43 @@ +@property --wp-view-transition-animation-wipe-angle { + syntax: ""; + initial-value: 270deg; + inherits: false; +} + +@property --wp-view-transition-animation-wipe-progress { + syntax: ""; + initial-value: 0; + inherits: false; +} + +@keyframes wp-view-transition-animation-wipe-new { + from { + --wp-view-transition-animation-wipe-progress: 0; + } + to { + --wp-view-transition-animation-wipe-progress: 1; + } +} + +::view-transition-old(*), +::view-transition-new(*) { + mix-blend-mode: normal; + backface-visibility: hidden; +} + +::view-transition-old(root) { + opacity: 1; + transform: none; + animation: none 1.2s cubic-bezier(0.45, 0, 0.35, 1.0); + animation-fill-mode: both; + animation-delay: 0s; +} + +::view-transition-new(root) { + opacity: 1; + transform: none; + animation: wp-view-transition-animation-wipe-new 1.2s cubic-bezier(0.45, 0, 0.35, 1.0); + animation-fill-mode: both; + -webkit-mask-image: linear-gradient(var(--wp-view-transition-animation-wipe-angle), #000 calc(-70% + calc(170% * var(--wp-view-transition-animation-wipe-progress, 0))), transparent calc(170% * var(--wp-view-transition-animation-wipe-progress, 0))); + mask-image: linear-gradient(var(--wp-view-transition-animation-wipe-angle), #000 calc(-70% + calc(170% * var(--wp-view-transition-animation-wipe-progress, 0))), transparent calc(170% * var(--wp-view-transition-animation-wipe-progress, 0))); +} diff --git a/src/wp-includes/css/view-transitions.css b/src/wp-includes/css/view-transitions.css new file mode 100644 index 0000000000000..8f778e466b6b1 --- /dev/null +++ b/src/wp-includes/css/view-transitions.css @@ -0,0 +1,3 @@ +@view-transition { + navigation: auto; +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 936bbb6a8673f..6025ea044e477 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -625,6 +625,9 @@ add_action( 'wp_enqueue_scripts', 'wp_enqueue_stored_styles' ); add_action( 'wp_footer', 'wp_enqueue_stored_styles', 1 ); +// View transitions. +add_action( 'wp_enqueue_scripts', 'wp_load_view_transitions' ); + add_action( 'wp_default_styles', 'wp_default_styles' ); add_filter( 'style_loader_src', 'wp_style_loader_src', 10, 2 ); diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php index b4a71d855a377..82401373fed8d 100644 --- a/src/wp-includes/theme.php +++ b/src/wp-includes/theme.php @@ -2934,6 +2934,56 @@ function add_theme_support( $feature, ...$args ) { return false; } + + break; + + case 'view-transitions': + $defaults = array( + 'post-selector' => '.wp-block-post.post, article.post, body.single main', + 'global-transition-names' => array( + 'header' => 'header', + 'main' => 'main', + ), + 'post-transition-names' => array( + '.wp-block-post-title, .entry-title' => 'post-title', + '.wp-post-image' => 'post-thumbnail', + '.wp-block-post-content, .entry-content' => 'post-content', + ), + 'default-animation' => 'fade', + 'chronological-forwards-animation' => false, + 'chronological-backwards-animation' => false, + 'pagination-forwards-animation' => false, + 'pagination-backwards-animation' => false, + ); + if ( true === $args ) { + $args = $defaults; + } else { + $args = wp_parse_args( $args, $defaults ); + + // Enforce correct types. + if ( ! is_array( $args['global-transition-names'] ) ) { + $args['global-transition-names'] = array(); + } + if ( ! is_array( $args['post-transition-names'] ) ) { + $args['post-transition-names'] = array(); + } + + // If specific transition animations match the default animations, they are irrelevant. + if ( $args['chronological-forwards-animation'] === $args['default-animation'] ) { + $args['chronological-forwards-animation'] = false; + } + if ( $args['chronological-backwards-animation'] === $args['default-animation'] ) { + $args['chronological-backwards-animation'] = false; + } + if ( $args['pagination-forwards-animation'] === $args['default-animation'] ) { + $args['pagination-forwards-animation'] = false; + } + if ( $args['pagination-backwards-animation'] === $args['default-animation'] ) { + $args['pagination-backwards-animation'] = false; + } + } + + break; } $_wp_theme_features[ $feature ] = $args; @@ -4399,6 +4449,9 @@ function _add_default_theme_supports() { add_theme_support( 'html5', array( 'comment-form', 'comment-list', 'search-form', 'gallery', 'caption', 'style', 'script' ) ); add_theme_support( 'automatic-feed-links' ); + // Block themes can always support view transitions with the default configuration. + add_theme_support( 'view-transitions' ); + add_filter( 'should_load_separate_core_block_assets', '__return_true' ); add_filter( 'should_load_block_assets_on_demand', '__return_true' ); @@ -4421,3 +4474,332 @@ static function ( $active, WP_Customize_Panel $panel ) { 2 ); } + +/** + * Loads view transitions for the current theme, if configured. + * + * @since 6.9.0 + */ +function wp_load_view_transitions() { + if ( ! current_theme_supports( 'view-transitions' ) ) { + return; + } + + // Instantiate animation registry. + $animation_registry = new WP_View_Transition_Animation_Registry(); + + // Register default animations. + $animation_registry->register_animation( + 'fade', // This is how view transitions are animated without any extra CSS. + array( + 'use_stylesheet' => false, + 'use_global_transition_names' => true, + 'use_post_transition_names' => true, + ) + ); + $animation_registry->register_animation( + 'slide', + array( + 'aliases' => array( + 'slide-from-right', + 'slide-from-bottom', + 'slide-from-left', + 'slide-from-top', + ), + 'use_stylesheet' => true, + 'use_global_transition_names' => function ( string $alias, array $args ) { + // If no specific element is targeted, the entire screen should be moved. + return '*' === $args['target-name'] ? false : true; + }, + 'use_post_transition_names' => function ( string $alias, array $args ) { + // If no specific element is targeted, the entire screen should be moved. + return '*' === $args['target-name'] ? false : true; + }, + 'get_stylesheet_callback' => function ( string $css, string $alias, array $args ) { + // Set offsets based on alias, if relevant. + if ( str_ends_with( $alias, 'left' ) ) { + $args['horizontal-offset'] = -1; + $args['vertical-offset'] = 0; + } elseif ( str_ends_with( $alias, 'top' ) ) { + $args['horizontal-offset'] = 0; + $args['vertical-offset'] = -1; + } elseif ( str_ends_with( $alias, 'bottom' ) ) { + $args['horizontal-offset'] = 0; + $args['vertical-offset'] = 1; + } elseif ( str_ends_with( $alias, 'right' ) ) { + $args['horizontal-offset'] = 1; + $args['vertical-offset'] = 0; + } + + // Inject offsets as CSS variable to take effect. + $css .= sprintf( + '::view-transition-old(*), ::view-transition-new(*) { --wp-view-transition-animation-slide-horizontal-offset: %d; --wp-view-transition-animation-slide-vertical-offset: %d; }', + $args['horizontal-offset'], + $args['vertical-offset'] + ); + + // If a specific element view transition name is targeted, scope the animation to only that name. + if ( '*' !== $args['target-name'] ) { + $css = str_replace( '(*)', "({$args['target-name']})", $css ); + } + + return $css; + }, + ), + array( + 'horizontal-offset' => 1, + 'vertical-offset' => 0, + 'target-name' => '*', + ) + ); + $animation_registry->register_animation( + 'swipe', + array( + 'aliases' => array( + 'swipe-from-right', + 'swipe-from-bottom', + 'swipe-from-left', + 'swipe-from-top', + ), + 'use_stylesheet' => true, + 'use_global_transition_names' => function ( string $alias, array $args ) { + // If no specific element is targeted, the entire screen should be moved. + return '*' === $args['target-name'] ? false : true; + }, + 'use_post_transition_names' => function ( string $alias, array $args ) { + // If no specific element is targeted, the entire screen should be moved. + return '*' === $args['target-name'] ? false : true; + }, + 'get_stylesheet_callback' => function ( string $css, string $alias, array $args ) { + // Set offsets based on alias, if relevant. + if ( str_ends_with( $alias, 'left' ) ) { + $args['horizontal-offset'] = -1; + $args['vertical-offset'] = 0; + } elseif ( str_ends_with( $alias, 'top' ) ) { + $args['horizontal-offset'] = 0; + $args['vertical-offset'] = -1; + } elseif ( str_ends_with( $alias, 'bottom' ) ) { + $args['horizontal-offset'] = 0; + $args['vertical-offset'] = 1; + } elseif ( str_ends_with( $alias, 'right' ) ) { + $args['horizontal-offset'] = 1; + $args['vertical-offset'] = 0; + } + + // Inject offsets as CSS variable to take effect. + $css .= sprintf( + '::view-transition-old(*), ::view-transition-new(*) { --wp-view-transition-animation-swipe-horizontal-offset: %d; --wp-view-transition-animation-swipe-vertical-offset: %d; }', + $args['horizontal-offset'], + $args['vertical-offset'] + ); + + // If a specific element view transition name is targeted, scope the animation to only that name. + if ( '*' !== $args['target-name'] ) { + $css = str_replace( '(*)', "({$args['target-name']})", $css ); + } + + return $css; + }, + ), + array( + 'horizontal-offset' => 1, + 'vertical-offset' => 0, + 'target-name' => '*', + ) + ); + $animation_registry->register_animation( + 'wipe', + array( + 'aliases' => array( + 'wipe-from-right', + 'wipe-from-bottom', + 'wipe-from-left', + 'wipe-from-top', + ), + 'use_stylesheet' => true, + 'use_global_transition_names' => false, + 'use_post_transition_names' => true, + 'get_stylesheet_callback' => function ( string $css, string $alias, array $args ) { + // Set angle based on alias, if relevant. + if ( str_ends_with( $alias, 'left' ) ) { + $args['angle'] = 90; + } elseif ( str_ends_with( $alias, 'top' ) ) { + $args['angle'] = 180; + } elseif ( str_ends_with( $alias, 'bottom' ) ) { + $args['angle'] = 0; + } elseif ( str_ends_with( $alias, 'right' ) ) { + $args['angle'] = 270; + } + + // Inject angle as CSS variable to take effect. + $css .= sprintf( + '::view-transition-new(root) { --wp-view-transition-animation-wipe-angle: %ddeg; }', + $args['angle'] + ); + + return $css; + }, + ), + array( 'angle' => 270 ) + ); + + /** + * Fires when view transitions are being loaded. + * + * This is only triggered if the theme supports view transitions, as otherwise the functionality is not relevant. + * + * @since 6.9.0 + * + * @param WP_View_Transition_Animation_Registry $animation_registry Registry instance to register view transition + * animations on, which can be used by the theme. + */ + do_action( 'wp_load_view_transitions', $animation_registry ); + + $suffix = wp_scripts_get_suffix(); + + $stylesheet = file_get_contents( ABSPATH . WPINC . "/css/view-transitions{$suffix}.css" ); + + // Use an inline style to avoid an extra request. + wp_register_style( 'wp-view-transitions', false, array(), null ); + wp_add_inline_style( 'wp-view-transitions', $stylesheet ); + wp_enqueue_style( 'wp-view-transitions' ); + + $theme_support = get_theme_support( 'view-transitions' ); + + /* + * Add the animation stylesheet for the default animation, if any. + */ + $default_animation_args = isset( $theme_support['default-animation-args'] ) ? (array) $theme_support['default-animation-args'] : array(); + $default_animation_stylesheet = $animation_registry->get_animation_stylesheet( $theme_support['default-animation'], $default_animation_args ); + if ( $default_animation_stylesheet ) { + wp_add_inline_style( 'wp-view-transitions', $default_animation_stylesheet ); + } + + /* + * No point in loading the script if no specific view transition names are configured and if no animations are + * configured other than for the default transition. + */ + if ( + ! $theme_support['global-transition-names'] && + ! $theme_support['post-transition-names'] && + ! $theme_support['chronological-forwards-animation'] && + ! $theme_support['chronological-backwards-animation'] && + ! $theme_support['pagination-forwards-animation'] && + ! $theme_support['pagination-backwards-animation'] + ) { + return; + } + + $animations_js_config = array( + 'default' => array( + 'useGlobalTransitionNames' => $animation_registry->use_animation_global_transition_names( $theme_support['default-animation'], $default_animation_args ), + 'usePostTransitionNames' => $animation_registry->use_animation_post_transition_names( $theme_support['default-animation'], $default_animation_args ), + 'targetName' => isset( $default_animation_args['target-name'] ) ? $default_animation_args['target-name'] : '*', // Special argument. + ), + ); + + $additional_transition_types = array( + 'chronological-forwards', + 'chronological-backwards', + 'pagination-forwards', + 'pagination-backwards', + ); + foreach ( $additional_transition_types as $transition_type ) { + if ( $theme_support[ $transition_type . '-animation' ] ) { + $additional_animation_args = isset( $theme_support[ $transition_type . '-animation-args' ] ) ? (array) $theme_support[ $transition_type . '-animation-args' ] : array(); + $additional_animation_stylesheet = $animation_registry->get_animation_stylesheet( $theme_support[ $transition_type . '-animation' ], $additional_animation_args ); + if ( $additional_animation_stylesheet ) { + wp_add_inline_style( + 'wp-view-transitions', + wp_view_transitions_scope_animation_stylesheet_to_transition_type( $additional_animation_stylesheet, $transition_type ) + ); + } + + $animations_js_config[ $transition_type ] = array( + 'useGlobalTransitionNames' => $animation_registry->use_animation_global_transition_names( $theme_support[ $transition_type . '-animation' ], $additional_animation_args ), + 'usePostTransitionNames' => $animation_registry->use_animation_post_transition_names( $theme_support[ $transition_type . '-animation' ], $additional_animation_args ), + 'targetName' => isset( $additional_animation_args['target-name'] ) ? $additional_animation_args['target-name'] : '*', // Special argument. + ); + } else { + $animations_js_config[ $transition_type ] = false; + } + } + + $config = array( + 'postSelector' => $theme_support['post-selector'], + 'globalTransitionNames' => $theme_support['global-transition-names'], + 'postTransitionNames' => $theme_support['post-transition-names'], + 'animations' => $animations_js_config, + ); + $src_script = file_get_contents( ABSPATH . WPINC . "/js/wp-view-transitions{$suffix}.js" ); + $init_script = sprintf( + 'wp.viewTransitions.init( %s )', + wp_json_encode( $config, JSON_FORCE_OBJECT ) + ); + + /* + * This must be in the , not in the footer. + * This is because the pagereveal event listener must be added before the first rAF occurs since that is when the event fires. See . + * An inline script is used to avoid an extra request. + */ + wp_register_script( 'wp-view-transitions', false, array(), null, array() ); + wp_add_inline_script( 'wp-view-transitions', $src_script ); + wp_add_inline_script( 'wp-view-transitions', $init_script ); + wp_enqueue_script( 'wp-view-transitions' ); +} + +/** + * Scopes the given view transition animation CSS to apply only to a specific transition type. + * + * @since 6.9.0 + * @access private + * + * @param string $css Animation stylesheet as inline CSS. + * @param string $transition_type Transition type to scope the CSS to. + * @return string Scoped animation stylesheet. + */ +function wp_view_transitions_scope_animation_stylesheet_to_transition_type( string $css, string $transition_type ): string { + $indent = function ( string $input, $indent_tabs = 1 ): string { + return implode( + "\n", + array_map( + function ( string $line ) use ( $indent_tabs ): string { + return str_repeat( "\t", $indent_tabs ) . $line; + }, + explode( "\n", $input ) + ) + ); + }; + + // This is very fragile, but it works well enough for now. TODO: Find a better solution to scope the CSS selectors. + if ( preg_match_all( '/(\s*)([^{}]+)\{[^{}]*?\}/m', $css, $matches ) ) { + // Wrap all `::view-transition-*` selectors to scope them to the transition type. + $view_transition_rule_pattern = '/::view-transition-/'; + + foreach ( $matches[0] as $index => $match ) { + $rule = $match; + $rule_name = $matches[2][ $index ]; + if ( preg_match( $view_transition_rule_pattern, $rule_name ) ) { + $rule_whitespace = $matches[1][ $index ]; + $prefixed_rule_name = preg_replace( $view_transition_rule_pattern, '&\0', $rule_name ); + $rule = str_replace( $rule_name, $prefixed_rule_name, $rule ); + + if ( str_contains( $rule, "\n" ) ) { // Non-minified. + $rule = $rule_whitespace . + "html:active-view-transition-type({$transition_type}) {\n" . + $indent( substr( $rule, strlen( $rule_whitespace ) ), 1 ) . + "\n}"; + } else { // Minified. + $rule = $rule_whitespace . + "html:active-view-transition-type({$transition_type}){" . + substr( $rule, strlen( $rule_whitespace ) ) . + '}'; + } + + // Replace the original rule with the wrapped/scoped one. + $css = str_replace( $match, $rule, $css ); + } + } + } + return $css; +} diff --git a/src/wp-settings.php b/src/wp-settings.php index f5a0929db87c1..b5da022e0b80e 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -409,6 +409,8 @@ require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php'; require ABSPATH . WPINC . '/class-wp-speculation-rules.php'; require ABSPATH . WPINC . '/speculative-loading.php'; +require ABSPATH . WPINC . '/class-wp-view-transition-animation.php'; +require ABSPATH . WPINC . '/class-wp-view-transition-animation-registry.php'; add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) ); add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) ); diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 0f8ddb2d9bbf3..e68a5fb876e9e 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -892,6 +892,10 @@ public function test_import_theme_starter_content_with_nested_arrays() { */ public function test_customize_preview_init() { + // `wp_load_view_transitions()` assumes `wp-includes/js/wp-view-transitions.js` is present: + $suffix = wp_scripts_get_suffix(); + self::touch( ABSPATH . WPINC . "/js/wp-view-transitions{$suffix}.js" ); + // Test authorized admin user. wp_set_current_user( self::$admin_user_id ); $did_action_customize_preview_init = did_action( 'customize_preview_init' );