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,
];