diff --git a/.eslintrc.js b/.eslintrc.js index 1810027392..00e45787e2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,6 +25,18 @@ const config = { '/dist', '/**/*.min.js', ], + overrides: [ + ...( wpConfig?.overrides || [] ), + { + files: [ 'plugins/view-transitions/js/**/*.js' ], + rules: { + 'jsdoc/no-undefined-types': [ + 'error', + { definedTypes: [ 'PageSwapEvent', 'PageRevealEvent' ] }, + ], + }, + }, + ], }; module.exports = config; diff --git a/plugins/view-transitions/hooks.php b/plugins/view-transitions/hooks.php index df3903f708..7c8981cca0 100644 --- a/plugins/view-transitions/hooks.php +++ b/plugins/view-transitions/hooks.php @@ -29,4 +29,5 @@ function plvt_render_generator(): void { * Filters related to the View Transitions functionality. */ add_action( 'after_setup_theme', 'plvt_polyfill_theme_support', PHP_INT_MAX ); +add_action( 'init', 'plvt_sanitize_view_transitions_theme_support', 1 ); add_action( 'wp_enqueue_scripts', 'plvt_load_view_transitions' ); diff --git a/plugins/view-transitions/includes/functions.php b/plugins/view-transitions/includes/functions.php new file mode 100644 index 0000000000..7a57830199 --- /dev/null +++ b/plugins/view-transitions/includes/functions.php @@ -0,0 +1,53 @@ + $_wp_theme_features Theme support features added and their arguments. + */ +function plvt_sanitize_view_transitions_theme_support(): void { + global $_wp_theme_features; + + if ( ! isset( $_wp_theme_features['view-transitions'] ) ) { + return; + } + + $args = $_wp_theme_features['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', + ), + ); + + // If no specific `$args` were provided, simply use the defaults. + if ( true === $args ) { + $args = $defaults; + } else { + /* + * By default, `add_theme_support()` will take all function parameters as `$args`, but for the + * 'view-transitions' feature, only a single associative array of arguments is relevant, which is expected as + * the sole (optional) parameter. + */ + if ( count( $args ) === 1 && isset( $args[0] ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + + $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(); + } + } + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $_wp_theme_features['view-transitions'] = $args; +} + /** * Loads view transitions based on the current configuration. * @@ -42,4 +103,44 @@ function plvt_load_view_transitions(): void { wp_register_style( 'wp-view-transitions', false, array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion wp_add_inline_style( 'wp-view-transitions', $stylesheet ); wp_enqueue_style( 'wp-view-transitions' ); + + $theme_support = get_theme_support( 'view-transitions' ); + + /* + * No point in loading the script if no specific view transition names are configured. + */ + if ( + ( ! is_array( $theme_support['global-transition-names'] ) || count( $theme_support['global-transition-names'] ) === 0 ) && + ( ! is_array( $theme_support['post-transition-names'] ) || count( $theme_support['post-transition-names'] ) === 0 ) + ) { + return; + } + + $config = array( + 'postSelector' => $theme_support['post-selector'], + 'globalTransitionNames' => $theme_support['global-transition-names'], + 'postTransitionNames' => $theme_support['post-transition-names'], + ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $src_script = file_get_contents( plvt_get_asset_path( 'js/view-transitions.js' ) ); + if ( false === $src_script || '' === $src_script ) { + // This clause should never be entered, but is needed to please PHPStan. Can't hurt to be safe. + return; + } + + $init_script = sprintf( + 'plvtInitViewTransitions( %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() ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion + wp_add_inline_script( 'wp-view-transitions', $src_script ); + wp_add_inline_script( 'wp-view-transitions', $init_script ); + wp_enqueue_script( 'wp-view-transitions' ); } diff --git a/plugins/view-transitions/js/types.ts b/plugins/view-transitions/js/types.ts new file mode 100644 index 0000000000..678ca40a52 --- /dev/null +++ b/plugins/view-transitions/js/types.ts @@ -0,0 +1,21 @@ +export type ViewTransitionsConfig = { + postSelector?: string; + globalTransitionNames?: Record< string, string >; + postTransitionNames?: Record< string, string >; +}; + +export type InitViewTransitionsFunction = ( + config: ViewTransitionsConfig +) => void; + +declare global { + interface Window { + plvtInitViewTransitions?: InitViewTransitionsFunction; + navigation?: { + activation: NavigationActivation; + }; + } +} + +export type PageSwapListenerFunction = ( event: PageSwapEvent ) => void; +export type PageRevealListenerFunction = ( event: PageRevealEvent ) => void; diff --git a/plugins/view-transitions/js/view-transitions.js b/plugins/view-transitions/js/view-transitions.js new file mode 100644 index 0000000000..07185097a3 --- /dev/null +++ b/plugins/view-transitions/js/view-transitions.js @@ -0,0 +1,197 @@ +/** + * @typedef {import("./types.ts").ViewTransitionsConfig} ViewTransitionsConfig + * @typedef {import("./types.ts").InitViewTransitionsFunction} InitViewTransitionsFunction + * @typedef {import("./types.ts").PageSwapListenerFunction} PageSwapListenerFunction + * @typedef {import("./types.ts").PageRevealListenerFunction} PageRevealListenerFunction + */ + +/** + * Initializes view transitions for the current URL. + * + * @type {InitViewTransitionsFunction} + * @param {ViewTransitionsConfig} config - The view transitions configuration. + */ +window.plvtInitViewTransitions = ( 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 {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 = ( bodyElement, articleElement ) => { + const globalEntries = Object.entries( + config.globalTransitionNames || {} + ).map( ( [ selector, name ] ) => { + const element = bodyElement.querySelector( selector ); + return [ element, name ]; + } ); + + const postEntries = 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 ); + }; + + /** + * Customizes view transition behavior on the URL that is being navigated from. + * + * @type {PageSwapListenerFunction} + * @param {PageSwapEvent} event - Event fired as the previous URL is about to unload. + */ + window.addEventListener( + 'pageswap', + ( /** @type {PageSwapEvent} */ event ) => { + if ( event.viewTransition ) { + let viewTransitionEntries; + if ( document.body.classList.contains( 'single' ) ) { + viewTransitionEntries = getViewTransitionEntries( + document.body, + getArticle() + ); + } else if ( + document.body.classList.contains( 'home' ) || + document.body.classList.contains( 'archive' ) + ) { + viewTransitionEntries = getViewTransitionEntries( + document.body, + getArticleForUrl( event.activation.entry.url ) + ); + } + if ( viewTransitionEntries ) { + setTemporaryViewTransitionNames( + viewTransitionEntries, + event.viewTransition.finished + ); + } + } + } + ); + + /** + * Customizes view transition behavior on the URL that is being navigated to. + * + * @type {PageRevealListenerFunction} + * @param {PageRevealEvent} event - Event fired as the new URL being navigated to is loaded. + */ + window.addEventListener( + 'pagereveal', + ( /** @type {PageRevealEvent} */ event ) => { + if ( event.viewTransition ) { + let viewTransitionEntries; + if ( document.body.classList.contains( 'single' ) ) { + viewTransitionEntries = getViewTransitionEntries( + document.body, + getArticle() + ); + } else if ( + document.body.classList.contains( 'home' ) || + document.body.classList.contains( 'archive' ) + ) { + viewTransitionEntries = getViewTransitionEntries( + document.body, + window.navigation.activation.from + ? getArticleForUrl( + window.navigation.activation.from.url + ) + : null + ); + } + if ( viewTransitionEntries ) { + setTemporaryViewTransitionNames( + viewTransitionEntries, + event.viewTransition.ready + ); + } + } + } + ); +}; diff --git a/plugins/view-transitions/readme.txt b/plugins/view-transitions/readme.txt index 7fb27b817f..55115c2944 100644 --- a/plugins/view-transitions/readme.txt +++ b/plugins/view-transitions/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.8 -Stable tag: 1.4.0 +Stable tag: 1.0.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, view transitions, smooth transitions, animations diff --git a/plugins/view-transitions/tests/test-hooks.php b/plugins/view-transitions/tests/test-hooks.php index 505d4f3c77..4483052a4d 100644 --- a/plugins/view-transitions/tests/test-hooks.php +++ b/plugins/view-transitions/tests/test-hooks.php @@ -11,6 +11,7 @@ class Test_ViewTransitions_Hooks extends WP_UnitTestCase { public function test_hooks(): void { $this->assertSame( 10, has_action( 'wp_head', 'plvt_render_generator' ) ); $this->assertSame( PHP_INT_MAX, has_action( 'after_setup_theme', 'plvt_polyfill_theme_support' ) ); + $this->assertSame( 1, has_action( 'init', 'plvt_sanitize_view_transitions_theme_support' ) ); $this->assertSame( 10, has_action( 'wp_enqueue_scripts', 'plvt_load_view_transitions' ) ); } diff --git a/plugins/view-transitions/tests/test-theme.php b/plugins/view-transitions/tests/test-theme.php index 5e43560cf1..f5faa36da2 100644 --- a/plugins/view-transitions/tests/test-theme.php +++ b/plugins/view-transitions/tests/test-theme.php @@ -36,6 +36,7 @@ public function test_plvt_load_view_transitions(): void { // Test that with theme support it registers and enqueues the style. add_theme_support( 'view-transitions' ); + plvt_sanitize_view_transitions_theme_support(); // This must be called to sanitize the arguments (normally on 'init'). plvt_load_view_transitions(); $this->assertTrue( wp_style_is( 'wp-view-transitions', 'registered' ) ); $this->assertTrue( wp_style_is( 'wp-view-transitions', 'enqueued' ) ); diff --git a/plugins/view-transitions/view-transitions.php b/plugins/view-transitions/view-transitions.php index 0e508f30f5..03ba7434ad 100644 --- a/plugins/view-transitions/view-transitions.php +++ b/plugins/view-transitions/view-transitions.php @@ -27,6 +27,7 @@ define( 'VIEW_TRANSITIONS_VERSION', '1.0.0' ); +require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/theme.php'; require_once __DIR__ . '/hooks.php'; // @codeCoverageIgnoreEnd diff --git a/webpack.config.js b/webpack.config.js index 5981f9bf76..7f6f84a21a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -22,6 +22,13 @@ const { */ const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); +/* + * Temporary workaround because 'view-transitions' should not be added to `plugins.json` just yet, since it is not + * ready to be released. + * TODO: Remove this workaround once the plugin is added to `plugins.json`. + */ +standalonePlugins.push( 'view-transitions' ); + const defaultBuildConfig = { entry: {}, output: { @@ -40,6 +47,7 @@ const pluginsWithBuild = [ 'embed-optimizer', 'image-prioritizer', 'optimization-detective', + 'view-transitions', 'web-worker-offloading', ]; @@ -217,6 +225,39 @@ const optimizationDetective = ( env ) => { }; }; +/** + * Webpack Config: View Transitions + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const viewTransitions = ( env ) => { + if ( env.plugin && env.plugin !== 'view-transitions' ) { + return defaultBuildConfig; + } + + const destination = path.resolve( __dirname, 'plugins/view-transitions' ); + + return { + ...sharedConfig, + name: 'view-transitions', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ destination }/js/view-transitions.js`, + to: `${ destination }/js/view-transitions.min.js`, + }, + ], + } ), + new WebpackBar( { + name: 'Building View Transitions Assets', + color: '#2196f3', + } ), + ], + }; +}; + /** * Webpack Config: Web Worker Offloading * @@ -347,6 +388,7 @@ module.exports = [ embedOptimizer, imagePrioritizer, optimizationDetective, + viewTransitions, webWorkerOffloading, buildPlugin, ];