diff --git a/plugins/view-transitions/includes/settings.php b/plugins/view-transitions/includes/settings.php index 4242dfc6de..f4d0df4d50 100644 --- a/plugins/view-transitions/includes/settings.php +++ b/plugins/view-transitions/includes/settings.php @@ -21,19 +21,22 @@ */ function plvt_get_view_transition_animation_labels(): array { return array( - 'fade' => _x( 'Fade (default)', 'animation label', 'view-transitions' ), - 'slide-from-right' => _x( 'Slide (from right)', 'animation label', 'view-transitions' ), - 'slide-from-left' => _x( 'Slide (from left)', 'animation label', 'view-transitions' ), - 'slide-from-bottom' => _x( 'Slide (from bottom)', 'animation label', 'view-transitions' ), - 'slide-from-top' => _x( 'Slide (from top)', 'animation label', 'view-transitions' ), - 'swipe-from-right' => _x( 'Swipe (from right)', 'animation label', 'view-transitions' ), - 'swipe-from-left' => _x( 'Swipe (from left)', 'animation label', 'view-transitions' ), - 'swipe-from-bottom' => _x( 'Swipe (from bottom)', 'animation label', 'view-transitions' ), - 'swipe-from-top' => _x( 'Swipe (from top)', 'animation label', 'view-transitions' ), - 'wipe-from-right' => _x( 'Wipe (from right)', 'animation label', 'view-transitions' ), - 'wipe-from-left' => _x( 'Wipe (from left)', 'animation label', 'view-transitions' ), - 'wipe-from-bottom' => _x( 'Wipe (from bottom)', 'animation label', 'view-transitions' ), - 'wipe-from-top' => _x( 'Wipe (from top)', 'animation label', 'view-transitions' ), + 'fade' => _x( 'Fade (default)', 'animation label', 'view-transitions' ), + 'slide-from-right' => _x( 'Slide (from right)', 'animation label', 'view-transitions' ), + 'slide-from-left' => _x( 'Slide (from left)', 'animation label', 'view-transitions' ), + 'slide-from-bottom' => _x( 'Slide (from bottom)', 'animation label', 'view-transitions' ), + 'slide-from-top' => _x( 'Slide (from top)', 'animation label', 'view-transitions' ), + 'slide-chronological-pagination' => _x( 'Slide (Chronological and Pagination)', 'animation label', 'view-transitions' ), + 'swipe-from-right' => _x( 'Swipe (from right)', 'animation label', 'view-transitions' ), + 'swipe-from-left' => _x( 'Swipe (from left)', 'animation label', 'view-transitions' ), + 'swipe-from-bottom' => _x( 'Swipe (from bottom)', 'animation label', 'view-transitions' ), + 'swipe-from-top' => _x( 'Swipe (from top)', 'animation label', 'view-transitions' ), + 'swipe-chronological-pagination' => _x( 'Swipe (Chronological and Pagination)', 'animation label', 'view-transitions' ), + 'wipe-from-right' => _x( 'Wipe (from right)', 'animation label', 'view-transitions' ), + 'wipe-from-left' => _x( 'Wipe (from left)', 'animation label', 'view-transitions' ), + 'wipe-from-bottom' => _x( 'Wipe (from bottom)', 'animation label', 'view-transitions' ), + 'wipe-from-top' => _x( 'Wipe (from top)', 'animation label', 'view-transitions' ), + 'wipe-chronological-pagination' => _x( 'Wipe (Chronological and Pagination)', 'animation label', 'view-transitions' ), ); } @@ -231,7 +234,22 @@ function plvt_apply_settings_to_theme_support(): void { // Apply the settings. $args['default-animation'] = $options['default_transition_animation']; $args['default-animation-duration'] = absint( $options['default_transition_animation_duration'] ); - $selector_options = array( + + // Automatically enable chronological and pagination animations for special animation options. + $chronological_pagination_animations = array( + 'slide-chronological-pagination' => 'slide', + 'swipe-chronological-pagination' => 'swipe', + 'wipe-chronological-pagination' => 'wipe', + ); + if ( isset( $chronological_pagination_animations[ $args['default-animation'] ] ) ) { + $base_animation = $chronological_pagination_animations[ $args['default-animation'] ]; + $args['chronological-forwards-animation'] = $base_animation . '-from-right'; + $args['chronological-backwards-animation'] = $base_animation . '-from-left'; + $args['pagination-forwards-animation'] = $base_animation . '-from-right'; + $args['pagination-backwards-animation'] = $base_animation . '-from-left'; + } + + $selector_options = array( 'global' => array( 'header_selector' => 'header', 'main_selector' => 'main', diff --git a/plugins/view-transitions/includes/theme.php b/plugins/view-transitions/includes/theme.php index fb2ad15ada..f1d7dae417 100644 --- a/plugins/view-transitions/includes/theme.php +++ b/plugins/view-transitions/includes/theme.php @@ -66,18 +66,22 @@ function plvt_sanitize_view_transitions_theme_support(): void { $args = $_wp_theme_features['view-transitions']; $defaults = array( - 'post-selector' => '.wp-block-post.post, article.post, body.single main', - 'global-transition-names' => array( + 'post-selector' => '.wp-block-post.post, article.post, body.single main', + 'global-transition-names' => array( 'header' => 'header', 'main' => 'main', ), - 'post-transition-names' => array( + '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', - 'default-animation-duration' => 400, + 'default-animation' => 'fade', + 'default-animation-duration' => 400, + 'chronological-forwards-animation' => false, + 'chronological-backwards-animation' => false, + 'pagination-forwards-animation' => false, + 'pagination-backwards-animation' => false, ); // If no specific `$args` were provided, simply use the defaults. @@ -102,8 +106,21 @@ function plvt_sanitize_view_transitions_theme_support(): void { 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; + } + } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $_wp_theme_features['view-transitions'] = $args; } @@ -324,7 +341,11 @@ function plvt_load_view_transitions(): void { */ 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 ) + ( ! is_array( $theme_support['post-transition-names'] ) || count( $theme_support['post-transition-names'] ) === 0 ) && + ! (bool) $theme_support['chronological-forwards-animation'] && + ! (bool) $theme_support['chronological-backwards-animation'] && + ! (bool) $theme_support['pagination-forwards-animation'] && + ! (bool) $theme_support['pagination-backwards-animation'] ) { return; } @@ -336,6 +357,34 @@ function plvt_load_view_transitions(): void { ), ); + $additional_transition_types = array( + 'chronological-forwards', + 'chronological-backwards', + 'pagination-forwards', + 'pagination-backwards', + ); + + foreach ( $additional_transition_types as $transition_type ) { + if ( isset( $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( + 'plvt-view-transitions', + plvt_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'], @@ -389,3 +438,63 @@ function plvt_inject_animation_duration( string $css, int $animation_duration ): return $css; } + +/** + * Scopes the given view transition animation CSS to apply only to a specific transition type. + * + * @since n.e.x.t + * @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 plvt_scope_animation_stylesheet_to_transition_type( string $css, string $transition_type ): string { + $indent = static function ( string $input, $indent_tabs = 1 ): string { + return implode( + "\n", + array_map( + static 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 ( (bool) 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 ( (bool) 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 ); + if ( null === $prefixed_rule_name ) { + continue; + } + + $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/plugins/view-transitions/js/types.ts b/plugins/view-transitions/js/types.ts index c1509d37d4..1653b841c2 100644 --- a/plugins/view-transitions/js/types.ts +++ b/plugins/view-transitions/js/types.ts @@ -14,6 +14,10 @@ export type InitViewTransitionsFunction = ( config: ViewTransitionsConfig ) => void; +export type NavigationHistoryEntry = { + url: string; +}; + declare global { interface Window { plvtInitViewTransitions?: InitViewTransitionsFunction; diff --git a/plugins/view-transitions/js/view-transitions.js b/plugins/view-transitions/js/view-transitions.js index 485e7bba10..fe98730486 100644 --- a/plugins/view-transitions/js/view-transitions.js +++ b/plugins/view-transitions/js/view-transitions.js @@ -3,6 +3,7 @@ * @typedef {import("./types.ts").InitViewTransitionsFunction} InitViewTransitionsFunction * @typedef {import("./types.ts").PageSwapListenerFunction} PageSwapListenerFunction * @typedef {import("./types.ts").PageRevealListenerFunction} PageRevealListenerFunction + * @typedef {import("./types.ts").NavigationHistoryEntry} NavigationHistoryEntry */ /** @@ -133,6 +134,156 @@ window.plvtInitViewTransitions = ( config ) => { return articleLink.closest( config.postSelector ); }; + /** + * Determines the view transition type to use, given an old and new navigation history entry. + * + * @param {NavigationHistoryEntry|null} 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 = null; + let newPageMatches = null; + 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'; + }; + /** * Customizes view transition behavior on the URL that is being navigated from. * @@ -143,9 +294,11 @@ window.plvtInitViewTransitions = ( config ) => { 'pageswap', ( /** @type {PageSwapEvent} */ event ) => { if ( event.viewTransition ) { - const transitionType = 'default'; // Only 'default' is supported so far, but more to be added. + const transitionType = determineTransitionType( + event.activation.from, + event.activation.entry + ); event.viewTransition.types.add( transitionType ); - let viewTransitionEntries; if ( document.body.classList.contains( 'single' ) ) { viewTransitionEntries = getViewTransitionEntries( @@ -184,7 +337,10 @@ window.plvtInitViewTransitions = ( config ) => { 'pagereveal', ( /** @type {PageRevealEvent} */ event ) => { if ( event.viewTransition ) { - const transitionType = 'default'; // Only 'default' is supported so far, but more to be added. + const transitionType = determineTransitionType( + window.navigation.activation.from, + window.navigation.activation.entry + ); event.viewTransition.types.add( transitionType ); let viewTransitionEntries;