From 676f25961bfab695bacb5dd7ebb10443e49bce23 Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Mon, 14 Aug 2023 10:45:14 -0400 Subject: [PATCH] Image block: Add aspect ratio support to lightbox (#52765) * Add docs for `afterLoad` * Move store options to the end * Initial aspect-ration support implementation * Move `inherit` property to `wp-style` directive * Add `object-fit` cover * Fix big images aspect ratio * Fix animation on mobile; add comments * Remove obsolete variable declaration * Fix PHP spacing * Optimize animation performance; add comments; fix fade and thumbnail bugs * Handle images in content with aspect ratios that differ from source images Note: This commit does not yet contain support for images with the `contain` style enabled, or for the images with 16x9 aspect ratio. For the moment, This code works by checking the aspect ratio of the image's natural dimensions and comparing it to the target dimensions. If these aspect ratios differ, then we calculate the space the lightbox image would use as a cover image and apply that as a `scale` CSS attribute. It also currently sets the image as a square using that resized dimension, which seems to work in many but not all cases, so will continue to explore the best way to approach. Animation for translation and scale have been separated in this commit to allow for flexibility in handling various scenarios. * Simplify zooming lightbox * Add support for thumbnails * Clean code and change variable names * Fix thumbnails with cropped width and height * Add support for contain setting * Add 5% padding to the lightbox container * Move CSS properties to style tag * Add wp prefix to CSS global variables * Remove `!important` from CSS * Reuse zooming logic for the fade animation * Remove fade conditional * Fix php standards * Remove unnecessary inheritSize selector * Update e2e test to fit new style tag * Fix width of image container so button isn't wider than image * Add variable horizontal padding and fixed vertical padding * Fix fade out animation; modify animation for image slightly * Prevent scroll after focusing last image * Add support for contain in lightbox image button * Add logic to set button styles only when needed and on image load * Simplify lazy loading logic * Remove extra div from image markup As part of this commit, I added handling for button styles in all images with the lightbox enabled, as we can't rely on the CSS as was written to set the right dimensions without breaking the layout on mobile. * Add 1 pixel to container dimensions to fix style bug on iOS * Add logic to center button when using 'contain' * (After merge) Add logic to set button styles on window resize * Replace loading=lazy with manual setting of src attribute The combination of 'loading=lazy' and the 'hidden' attributes was breaking progressive image loading in Safari and Chrome; instead we are now taking what seems to be a more reliable approach to accomplish lazy loading of the enlarged image, namely setting the src manually when the lightbox is opened. *This was an approach we had implemented before, but we began using 'loading=lazy' during a code cleanup. * Add link to comment * Remove obsolete code * Update tests * Add clarifying comment to setting of responsive image src attribute * Update clarifying comment on 1px bug in iOS * Add namespace to state --------- Co-authored-by: David Arenas Co-authored-by: Mario Santos Co-authored-by: Carlos Bravo --- lib/block-supports/behaviors.php | 86 ++- packages/block-library/src/image/style.scss | 156 ++--- .../src/image/view-interactivity.js | 620 +++++++++++------- test/e2e/specs/editor/blocks/image.spec.js | 62 +- 4 files changed, 547 insertions(+), 377 deletions(-) diff --git a/lib/block-supports/behaviors.php b/lib/block-supports/behaviors.php index 95639178c2de41..b6692195eb5fb5 100644 --- a/lib/block-supports/behaviors.php +++ b/lib/block-supports/behaviors.php @@ -70,7 +70,11 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $aria_label = __( 'Enlarge image', 'gutenberg' ); - $alt_attribute = trim( $processor->get_attribute( 'alt' ) ); + $alt_attribute = $processor->get_attribute( 'alt' ); + + if ( null !== $alt_attribute ) { + $alt_attribute = trim( $alt_attribute ); + } if ( $alt_attribute ) { /* translators: %s: Image alt text. */ @@ -89,22 +93,27 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $z->next_tag( 'img' ); if ( isset( $block['attrs']['id'] ) ) { - $img_uploaded_src = wp_get_attachment_url( $block['attrs']['id'] ); - $img_metadata = wp_get_attachment_metadata( $block['attrs']['id'] ); - $img_width = $img_metadata['width']; - $img_height = $img_metadata['height']; - $img_uploaded_srcset = wp_get_attachment_image_srcset( $block['attrs']['id'] ); + $img_uploaded_src = wp_get_attachment_url( $block['attrs']['id'] ); + $img_metadata = wp_get_attachment_metadata( $block['attrs']['id'] ); + $img_width = $img_metadata['width']; + $img_height = $img_metadata['height']; } else { - $img_uploaded_src = $z->get_attribute( 'src' ); - $img_width = 'none'; - $img_height = 'none'; - $img_uploaded_srcset = ''; + $img_uploaded_src = $z->get_attribute( 'src' ); + $img_width = 'none'; + $img_height = 'none'; + } + + if ( isset( $block['attrs']['scale'] ) ) { + $scale_attr = $block['attrs']['scale']; + } else { + $scale_attr = false; } $w = new WP_HTML_Tag_Processor( $content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); $w->set_attribute( 'data-wp-interactive', true ); + $w->set_attribute( 'data-wp-context', sprintf( @@ -118,47 +127,78 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { "lightboxAnimation": "%s", "imageUploadedSrc": "%s", "imageCurrentSrc": "", - "imageSrcSet": "%s", "targetWidth": "%s", - "targetHeight": "%s" + "targetHeight": "%s", + "scaleAttr": "%s" } } }', $lightbox_animation, $img_uploaded_src, - $img_uploaded_srcset, $img_width, - $img_height + $img_height, + $scale_attr ) ); $w->next_tag( 'img' ); - $w->set_attribute( 'data-wp-effect', 'effects.core.image.setCurrentSrc' ); + $w->set_attribute( 'data-wp-init', 'effects.core.image.setCurrentSrc' ); + $w->set_attribute( 'data-wp-on--load', 'actions.core.image.handleLoad' ); + $w->set_attribute( 'data-wp-effect', 'effects.core.image.setButtonStyles' ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. $img = null; preg_match( '/]+>/', $body_content, $img ); - $button = '
- ' - . $img[0] . - '
'; + $button = + '' + . $img[0]; $body_content = preg_replace( '/]+>/', $button, $body_content ); - // Add src to the modal image. + // We need both a responsive image and an enlarged image to animate + // the zoom seamlessly on slow internet connections; the responsive + // image is a copy of the one in the body, which animates immediately + // as the lightbox is opened, while the enlarged one is a full-sized + // version that will likely still be loading as the animation begins. $m = new WP_HTML_Tag_Processor( $content ); $m->next_tag( 'figure' ); $m->add_class( 'responsive-image' ); $m->next_tag( 'img' ); + // We want to set the 'src' attribute to an empty string in the responsive image + // because otherwise, as of this writing, the wp_filter_content_tags() function in + // WordPress will automatically add a 'srcset' attribute to the image, which will at + // times cause the incorrectly sized image to be loaded in the lightbox on Firefox. + // Because of this, we bind the 'src' attribute explicitly the current src to reliably + // use the exact same image as in the content when the lightbox is first opened while + // we wait for the larger image to load. $m->set_attribute( 'src', '' ); - $m->set_attribute( 'data-wp-bind--src', 'selectors.core.image.responsiveImgSrc' ); + $m->set_attribute( 'data-wp-bind--src', 'context.core.image.imageCurrentSrc' ); + $m->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); $q = new WP_HTML_Tag_Processor( $content ); $q->next_tag( 'figure' ); $q->add_class( 'enlarged-image' ); $q->next_tag( 'img' ); + + // We set the 'src' attribute to an empty string to prevent the browser from loading the image + // on initial page load, then bind the attribute to a selector that returns the full-sized image src when + // the lightbox is opened. We could use 'loading=lazy' in combination with the 'hidden' attribute to + // accomplish the same behavior, but that approach breaks progressive loading of the image in Safari + // and Chrome (see https://github.com/WordPress/gutenberg/pull/52765#issuecomment-1674008151). Until that + // is resolved, manually setting the 'src' seems to be the best solution to load the large image on demand. $q->set_attribute( 'src', '' ); $q->set_attribute( 'data-wp-bind--src', 'selectors.core.image.enlargedImgSrc' ); + $q->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); $enlarged_image_content = $q->get_updated_html(); $background_color = esc_attr( wp_get_global_styles( array( 'color', 'background' ) ) ); @@ -185,8 +225,8 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { - $initial_image_content - $enlarged_image_content + +
HTML; diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index e6816e873b7765..c7eec224f65878 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -153,10 +153,7 @@ } .wp-lightbox-container { - - .img-container { - position: relative; - } + position: relative; button { border: none; @@ -183,6 +180,7 @@ overflow: hidden; width: 100vw; height: 100vh; + box-sizing: border-box; visibility: hidden; cursor: zoom-out; @@ -195,25 +193,39 @@ z-index: 5000000; } + .lightbox-image-container { + position: absolute; + overflow: hidden; + top: 50%; + left: 50%; + transform-origin: top left; + transform: translate(-50%, -50%); + width: var(--wp--lightbox-container-width); + height: var(--wp--lightbox-container-height); + z-index: 9999999999; + } + .wp-block-image { + position: relative; + transform-origin: 0 0; + display: flex; width: 100%; height: 100%; - position: absolute; - z-index: 3000000; - box-sizing: border-box; - display: flex; justify-content: center; align-items: center; - flex-direction: column; + box-sizing: border-box; + z-index: 3000000; + margin: 0; - figcaption { - display: none; + img { + min-width: var(--wp--lightbox-image-width); + min-height: var(--wp--lightbox-image-height); + width: var(--wp--lightbox-image-width); + height: var(--wp--lightbox-image-height); } - img { - max-width: 100%; - max-height: 100%; - width: auto; + figcaption { + display: none; } } @@ -231,78 +243,61 @@ opacity: 0.9; } - &.fade { - .wp-block-image { - padding: 40px 0; - - @media screen and (min-width: 480px) { - padding: 40px; - } - - @media screen and (min-width: 1920px) { - padding: 40px 80px; - } + // When fading, make the image come in slightly slower + // or faster than the scrim to give a sense of depth. + &.active { + visibility: visible; + animation: both turn-on-visibility 0.25s; + img { + animation: both turn-on-visibility 0.35s; } - - &.active { - visibility: visible; - animation: both turn-on-visibility 0.25s; - + } + &.hideanimationenabled { + &:not(.active) { + animation: both turn-off-visibility 0.35s; img { - animation: both turn-on-visibility 0.3s; - } - } - &.hideanimationenabled { - &:not(.active) { - animation: both turn-off-visibility 0.3s; - - img { - animation: both turn-off-visibility 0.25s; - } + animation: both turn-off-visibility 0.25s; } } } - &.zoom { - img { - position: absolute; - transform-origin: top left; - width: var(--lightbox-image-max-width); - height: var(--lightbox-image-max-height); - } - - &.active { - opacity: 1; - visibility: visible; - .wp-block-image img { - animation: lightbox-zoom-in 0.4s forwards; - - @media (prefers-reduced-motion) { - animation: both turn-on-visibility 0.4s; - } - } - .scrim { - animation: turn-on-visibility 0.4s forwards; - } - } - &.hideanimationenabled { - &:not(.active) { - .wp-block-image img { - animation: lightbox-zoom-out 0.4s forwards; - - @media (prefers-reduced-motion) { - animation: both turn-off-visibility 0.4s; + @media (prefers-reduced-motion: no-preference) { + &.zoom { + &.active { + opacity: 1; + visibility: visible; + animation: none; + .lightbox-image-container { + animation: lightbox-zoom-in 0.4s; + // Override fade animation for image + img { + animation: none; } } .scrim { - animation: turn-off-visibility 0.4s forwards; + animation: turn-on-visibility 0.4s forwards; + } + } + &.hideanimationenabled { + &:not(.active) { + animation: none; + .lightbox-image-container { + animation: lightbox-zoom-out 0.4s; + // Override fade animation for image + img { + animation: none; + } + } + .scrim { + animation: turn-off-visibility 0.4s forwards; + } } } } } } -html.has-lightbox-open { +html.wp-has-lightbox-open { overflow: hidden; } @@ -332,30 +327,23 @@ html.has-lightbox-open { @keyframes lightbox-zoom-in { 0% { - left: var(--lightbox-initial-left-position); - top: var(--lightbox-initial-top-position); - transform: scale(var(--lightbox-scale-width), var(--lightbox-scale-height)); + transform: translate(calc(-50vw + var(--wp--lightbox-initial-left-position)), calc(-50vh + var(--wp--lightbox-initial-top-position))) scale(var(--wp--lightbox-scale)); } 100% { - left: var(--lightbox-target-left-position); - top: var(--lightbox-target-top-position); - transform: scale(1, 1); + transform: translate(-50%, -50%) scale(1, 1); } } @keyframes lightbox-zoom-out { 0% { visibility: visible; - left: var(--lightbox-target-left-position); - top: var(--lightbox-target-top-position); - transform: scale(1, 1); + transform: translate(-50%, -50%) scale(1, 1); } 99% { visibility: visible; } 100% { - left: var(--lightbox-initial-left-position); - top: var(--lightbox-initial-top-position); - transform: scale(var(--lightbox-scale-width), var(--lightbox-scale-height)); + visibility: hidden; + transform: translate(calc(-50vw + var(--wp--lightbox-initial-left-position)), calc(-50vh + var(--wp--lightbox-initial-top-position))) scale(var(--wp--lightbox-scale)); } } diff --git a/packages/block-library/src/image/view-interactivity.js b/packages/block-library/src/image/view-interactivity.js index e0fd68646964b4..53a2d1f7d567fc 100644 --- a/packages/block-library/src/image/view-interactivity.js +++ b/packages/block-library/src/image/view-interactivity.js @@ -17,293 +17,431 @@ const focusableSelectors = [ '[tabindex]:not([tabindex^="-"])', ]; -store( { - actions: { - core: { - image: { - showLightbox: ( { context, event } ) => { - // We can't initialize the lightbox until the reference - // image is loaded, otherwise the UX is broken. - if ( ! context.core.image.imageLoaded ) { - return; - } - context.core.image.initialized = true; - context.core.image.lastFocusedElement = - window.document.activeElement; - context.core.image.scrollDelta = 0; - - context.core.image.lightboxEnabled = true; - if ( context.core.image.lightboxAnimation === 'zoom' ) { - setZoomStyles( - event.target.nextElementSibling, - context, - event - ); - } - // Hide overflow only when the animation is in progress, - // otherwise the removal of the scrollbars will draw attention - // to itself and look like an error - document.documentElement.classList.add( - 'has-lightbox-open' - ); - - // Since the img is hidden and its src not loaded until - // the lightbox is opened, let's create an img element on the fly - // so we can get the dimensions we need to calculate the styles - context.core.image.preloadInitialized = true; - const imgDom = document.createElement( 'img' ); - imgDom.onload = function () { - context.core.image.activateLargeImage = true; - }; - imgDom.setAttribute( - 'src', - context.core.image.imageUploadedSrc - ); +store( + { + state: { + core: { + image: { + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, }, - hideLightbox: async ( { context, event } ) => { - context.core.image.hideAnimationEnabled = true; - if ( context.core.image.lightboxEnabled ) { - // If scrolling, wait a moment before closing the lightbox. - if ( context.core.image.lightboxAnimation === 'fade' ) { - context.core.image.scrollDelta += event.deltaY; - if ( - event.type === 'mousewheel' && - Math.abs( - window.scrollY - - context.core.image.scrollDelta - ) < 10 - ) { - return; - } - } else if ( - context.core.image.lightboxAnimation === 'zoom' - ) { - // Disable scroll until the zoom animation ends. - // Get the current page scroll position - const scrollTop = - window.pageYOffset || - document.documentElement.scrollTop; - const scrollLeft = - window.pageXOffset || - document.documentElement.scrollLeft; - // if any scroll is attempted, set this to the previous value. - window.onscroll = function () { - window.scrollTo( scrollLeft, scrollTop ); - }; - // Enable scrolling after the animation finishes - setTimeout( function () { - window.onscroll = function () {}; - }, 400 ); + }, + }, + actions: { + core: { + image: { + showLightbox: ( { context, event } ) => { + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + if ( ! context.core.image.imageLoaded ) { + return; } + context.core.image.initialized = true; + context.core.image.lastFocusedElement = + window.document.activeElement; + context.core.image.scrollDelta = 0; - document.documentElement.classList.remove( - 'has-lightbox-open' + context.core.image.lightboxEnabled = true; + setStyles( context, event ); + // Hide overflow only when the animation is in progress, + // otherwise the removal of the scrollbars will draw attention + // to itself and look like an error + document.documentElement.classList.add( + 'wp-has-lightbox-open' ); - - context.core.image.lightboxEnabled = false; - context.core.image.lastFocusedElement.focus(); - } - }, - handleKeydown: ( { context, actions, event } ) => { - if ( context.core.image.lightboxEnabled ) { - if ( event.key === 'Tab' || event.keyCode === 9 ) { - // If shift + tab it change the direction + }, + hideLightbox: async ( { context, event } ) => { + context.core.image.hideAnimationEnabled = true; + if ( context.core.image.lightboxEnabled ) { + // If scrolling, wait a moment before closing the lightbox. if ( - event.shiftKey && - window.document.activeElement === - context.core.image.firstFocusableElement + context.core.image.lightboxAnimation === 'fade' ) { - event.preventDefault(); - context.core.image.lastFocusableElement.focus(); + context.core.image.scrollDelta += event.deltaY; + if ( + event.type === 'mousewheel' && + Math.abs( + window.scrollY - + context.core.image.scrollDelta + ) < 10 + ) { + return; + } } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.image.lastFocusableElement + context.core.image.lightboxAnimation === 'zoom' ) { - event.preventDefault(); - context.core.image.firstFocusableElement.focus(); + // Disable scroll until the zoom animation ends. + // Get the current page scroll position + const scrollTop = + window.pageYOffset || + document.documentElement.scrollTop; + const scrollLeft = + window.pageXOffset || + document.documentElement.scrollLeft; + // if any scroll is attempted, set this to the previous value. + window.onscroll = function () { + window.scrollTo( scrollLeft, scrollTop ); + }; + // Enable scrolling after the animation finishes + setTimeout( function () { + window.onscroll = function () {}; + }, 400 ); } - } - if ( event.key === 'Escape' || event.keyCode === 27 ) { - actions.core.image.hideLightbox( { - context, - event, + document.documentElement.classList.remove( + 'wp-has-lightbox-open' + ); + + context.core.image.lightboxEnabled = false; + context.core.image.lastFocusedElement.focus( { + preventScroll: true, } ); } - } - }, - preloadLightboxImage: ( { context } ) => { - if ( ! context.core.image.preloadInitialized ) { - context.core.image.preloadInitialized = true; - const imgDom = document.createElement( 'img' ); - imgDom.setAttribute( - 'src', - context.core.image.imageUploadedSrc - ); - } + }, + handleKeydown: ( { context, actions, event } ) => { + if ( context.core.image.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction + if ( + event.shiftKey && + window.document.activeElement === + context.core.image.firstFocusableElement + ) { + event.preventDefault(); + context.core.image.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + context.core.image.lastFocusableElement + ) { + event.preventDefault(); + context.core.image.firstFocusableElement.focus(); + } + } + + if ( + event.key === 'Escape' || + event.keyCode === 27 + ) { + actions.core.image.hideLightbox( { + context, + event, + } ); + } + } + }, + handleLoad: ( { state, context, effects, ref } ) => { + context.core.image.imageLoaded = true; + context.core.image.imageCurrentSrc = ref.currentSrc; + effects.core.image.setButtonStyles( { + state, + context, + ref, + } ); + }, }, }, }, - }, - selectors: { - core: { - image: { - roleAttribute: ( { context } ) => { - return context.core.image.lightboxEnabled ? 'dialog' : ''; - }, - responsiveImgSrc: ( { context } ) => { - return context.core.image.activateLargeImage && - context.core.image.hideAnimationEnabled - ? '' - : context.core.image.imageCurrentSrc; - }, - enlargedImgSrc: ( { context } ) => { - return context.core.image.initialized - ? context.core.image.imageUploadedSrc - : ''; + selectors: { + core: { + image: { + roleAttribute: ( { context } ) => { + return context.core.image.lightboxEnabled + ? 'dialog' + : ''; + }, + lightboxObjectFit: ( { context } ) => { + if ( context.core.image.initialized ) { + return 'cover'; + } + }, + enlargedImgSrc: ( { context } ) => { + return context.core.image.initialized + ? context.core.image.imageUploadedSrc + : ''; + }, }, }, }, - }, - effects: { - core: { - image: { - setCurrentSrc: ( { context, ref } ) => { - if ( ref.complete ) { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - } else { - ref.addEventListener( 'load', function () { + effects: { + core: { + image: { + setCurrentSrc: ( { context, ref } ) => { + if ( ref.complete ) { context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = - this.currentSrc; - } ); - } - }, - initLightbox: async ( { context, ref } ) => { - context.core.image.figureRef = - ref.querySelector( 'figure' ); - context.core.image.imageRef = ref.querySelector( 'img' ); - if ( context.core.image.lightboxEnabled ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.image.firstFocusableElement = - focusableElements[ 0 ]; - context.core.image.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; + context.core.image.imageCurrentSrc = ref.currentSrc; + } + }, + initLightbox: async ( { context, ref } ) => { + context.core.image.figureRef = + ref.querySelector( 'figure' ); + context.core.image.imageRef = + ref.querySelector( 'img' ); + if ( context.core.image.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + context.core.image.firstFocusableElement = + focusableElements[ 0 ]; + context.core.image.lastFocusableElement = + focusableElements[ + focusableElements.length - 1 + ]; + + ref.querySelector( '.close-button' ).focus(); + } + }, + setButtonStyles: ( { state, context, ref } ) => { + const { + naturalWidth, + naturalHeight, + offsetWidth, + offsetHeight, + } = ref; + + // If the image isn't loaded yet, we can't + // calculate how big the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } - ref.querySelector( '.close-button' ).focus(); - } + // Subscribe to the window dimensions so we can + // recalculate the styles if the window is resized. + if ( + ( state.core.image.windowWidth || + state.core.image.windowHeight ) && + context.core.image.scaleAttr === 'contain' + ) { + // In the case of an image with object-fit: contain, the + // size of the img element can be larger than the image itself, + // so we need to calculate the size of the button to match. + + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio > offsetRatio ) { + // If it reaches the width first, keep + // the width and recalculate the height. + context.core.image.imageButtonWidth = + offsetWidth; + const buttonHeight = offsetWidth / naturalRatio; + context.core.image.imageButtonHeight = + buttonHeight; + context.core.image.imageButtonTop = + ( offsetHeight - buttonHeight ) / 2; + } else { + // If it reaches the height first, keep + // the height and recalculate the width. + context.core.image.imageButtonHeight = + offsetHeight; + const buttonWidth = offsetHeight * naturalRatio; + context.core.image.imageButtonWidth = + buttonWidth; + context.core.image.imageButtonLeft = + ( offsetWidth - buttonWidth ) / 2; + } + } else { + // In all other cases, we can trust that the size of + // the image is the right size for the button as well. + + context.core.image.imageButtonWidth = offsetWidth; + context.core.image.imageButtonHeight = offsetHeight; + } + }, }, }, }, }, -} ); + { + afterLoad: ( { state } ) => { + window.addEventListener( + 'resize', + debounce( () => { + state.core.image.windowWidth = window.innerWidth; + state.core.image.windowHeight = window.innerHeight; + } ) + ); + }, + } +); + +function setStyles( context, event ) { + // The reference img element lies adjacent + // to the event target button in the DOM. + let { + naturalWidth, + naturalHeight, + offsetWidth: originalWidth, + offsetHeight: originalHeight, + } = event.target.nextElementSibling; + let { x: screenPosX, y: screenPosY } = + event.target.nextElementSibling.getBoundingClientRect(); + + // Natural ratio of the image clicked to open the lightbox. + const naturalRatio = naturalWidth / naturalHeight; + // Original ratio of the image clicked to open the lightbox. + let originalRatio = originalWidth / originalHeight; + + // If it has object-fit: contain, recalculate the original sizes + // and the screen position without the blank spaces. + if ( context.core.image.scaleAttr === 'contain' ) { + if ( naturalRatio > originalRatio ) { + const heightWithoutSpace = originalWidth / naturalRatio; + // Recalculate screen position without the top space. + screenPosY += ( originalHeight - heightWithoutSpace ) / 2; + originalHeight = heightWithoutSpace; + } else { + const widthWithoutSpace = originalHeight * naturalRatio; + // Recalculate screen position without the left space. + screenPosX += ( originalWidth - widthWithoutSpace ) / 2; + originalWidth = widthWithoutSpace; + } + } + originalRatio = originalWidth / originalHeight; -function setZoomStyles( imgDom, context, event ) { // Typically, we use the image's full-sized dimensions. If those // dimensions have not been set (i.e. an external image with only one size), // the image's dimensions in the lightbox are the same // as those of the image in the content. - let targetWidth = + let imgMaxWidth = parseFloat( context.core.image.targetWidth !== 'none' ? context.core.image.targetWidth - : event.target.nextElementSibling.naturalWidth; - let targetHeight = + : naturalWidth + ); + let imgMaxHeight = parseFloat( context.core.image.targetHeight !== 'none' ? context.core.image.targetHeight - : event.target.nextElementSibling.naturalHeight; + : naturalHeight + ); + + // Ratio of the biggest image stored in the database. + let imgRatio = imgMaxWidth / imgMaxHeight; + let containerMaxWidth = imgMaxWidth; + let containerMaxHeight = imgMaxHeight; + let containerWidth = imgMaxWidth; + let containerHeight = imgMaxHeight; + // Check if the target image has a different ratio than the original one (thumbnail). + // Recalculate the width and height. + if ( naturalRatio.toFixed( 2 ) !== imgRatio.toFixed( 2 ) ) { + if ( naturalRatio > imgRatio ) { + // If the width is reached before the height, we keep the maxWidth + // and recalculate the height. + // Unless the difference between the maxHeight and the reducedHeight + // is higher than the maxWidth, where we keep the reducedHeight and + // recalculate the width. + const reducedHeight = imgMaxWidth / naturalRatio; + if ( imgMaxHeight - reducedHeight > imgMaxWidth ) { + imgMaxHeight = reducedHeight; + imgMaxWidth = reducedHeight * naturalRatio; + } else { + imgMaxHeight = imgMaxWidth / naturalRatio; + } + } else { + // If the height is reached before the width, we keep the maxHeight + // and recalculate the width. + // Unless the difference between the maxWidth and the reducedWidth + // is higher than the maxHeight, where we keep the reducedWidth and + // recalculate the height. + const reducedWidth = imgMaxHeight * naturalRatio; + if ( imgMaxWidth - reducedWidth > imgMaxHeight ) { + imgMaxWidth = reducedWidth; + imgMaxHeight = reducedWidth / naturalRatio; + } else { + imgMaxWidth = imgMaxHeight * naturalRatio; + } + } + containerWidth = imgMaxWidth; + containerHeight = imgMaxHeight; + imgRatio = imgMaxWidth / imgMaxHeight; - // Since the lightbox image has `position:absolute`, it - // ignores its parent's padding, so we need to set padding here - // to calculate dimensions and positioning. + // Calculate the max size of the container. + if ( originalRatio > imgRatio ) { + containerMaxWidth = imgMaxWidth; + containerMaxHeight = containerMaxWidth / originalRatio; + } else { + containerMaxHeight = imgMaxHeight; + containerMaxWidth = containerMaxHeight * originalRatio; + } + } - // As per the design, let's constrain the height with fixed padding - const containerOuterHeight = window.innerHeight; - const verticalPadding = 40; - const containerInnerHeight = containerOuterHeight - verticalPadding * 2; + // If the image has been pixelated on purpose, keep that size. + if ( originalWidth > containerWidth || originalHeight > containerHeight ) { + containerWidth = originalWidth; + containerHeight = originalHeight; + } - // Let's set a variable horizontal padding based on the container width - const containerOuterWidth = window.innerWidth; + // Calculate the final lightbox image size and the + // scale factor. MaxWidth is either the window container + // (accounting for padding) or the image resolution. let horizontalPadding = 0; - if ( containerOuterWidth > 480 ) { - horizontalPadding = 40; - } else if ( containerOuterWidth > 1920 ) { + if ( window.innerWidth > 480 ) { horizontalPadding = 80; + } else if ( window.innerWidth > 1920 ) { + horizontalPadding = 160; } - const containerInnerWidth = containerOuterWidth - horizontalPadding * 2; + const verticalPadding = 80; - // Check difference between the image and figure dimensions - const widthOverflow = Math.abs( - Math.min( containerInnerWidth - targetWidth, 0 ) + const targetMaxWidth = Math.min( + window.innerWidth - horizontalPadding, + containerWidth ); - const heightOverflow = Math.abs( - Math.min( containerInnerHeight - targetHeight, 0 ) + const targetMaxHeight = Math.min( + window.innerHeight - verticalPadding, + containerHeight ); + const targetContainerRatio = targetMaxWidth / targetMaxHeight; - // If the image is larger than the container, let's resize - // it along the greater axis relative to the container - if ( widthOverflow > 0 || heightOverflow > 0 ) { - const containerInnerAspectRatio = - containerInnerWidth / containerInnerHeight; - const imageAspectRatio = targetWidth / targetHeight; - - if ( imageAspectRatio > containerInnerAspectRatio ) { - targetWidth = containerInnerWidth; - targetHeight = - ( targetWidth * imgDom.naturalHeight ) / imgDom.naturalWidth; - } else { - targetHeight = containerInnerHeight; - targetWidth = - ( targetHeight * imgDom.naturalWidth ) / imgDom.naturalHeight; - } + if ( originalRatio > targetContainerRatio ) { + // If targetMaxWidth is reached before targetMaxHeight + containerWidth = targetMaxWidth; + containerHeight = containerWidth / originalRatio; + } else { + // If targetMaxHeight is reached before targetMaxWidth + containerHeight = targetMaxHeight; + containerWidth = containerHeight * originalRatio; } - // The reference img element lies adjacent to the event target button in the DOM - const { x: originLeft, y: originTop } = - event.target.nextElementSibling.getBoundingClientRect(); - const scaleWidth = - event.target.nextElementSibling.offsetWidth / targetWidth; - const scaleHeight = - event.target.nextElementSibling.offsetHeight / targetHeight; + const containerScale = originalWidth / containerWidth; + const lightboxImgWidth = + imgMaxWidth * ( containerWidth / containerMaxWidth ); + const lightboxImgHeight = + imgMaxHeight * ( containerHeight / containerMaxHeight ); - // Get values used to center the image - let targetLeft = 0; - if ( targetWidth >= containerInnerWidth ) { - targetLeft = horizontalPadding; - } else { - targetLeft = ( containerOuterWidth - targetWidth ) / 2; - } - let targetTop = 0; - if ( targetHeight >= containerInnerHeight ) { - targetTop = verticalPadding; - } else { - targetTop = ( containerOuterHeight - targetHeight ) / 2; + // Add the CSS variables needed. + let styleTag = document.getElementById( 'wp-lightbox-styles' ); + if ( ! styleTag ) { + styleTag = document.createElement( 'style' ); + styleTag.id = 'wp-lightbox-styles'; + document.head.appendChild( styleTag ); } - const root = document.documentElement; - root.style.setProperty( '--lightbox-scale-width', scaleWidth ); - root.style.setProperty( '--lightbox-scale-height', scaleHeight ); - root.style.setProperty( '--lightbox-image-max-width', targetWidth + 'px' ); - root.style.setProperty( - '--lightbox-image-max-height', - targetHeight + 'px' - ); - root.style.setProperty( - '--lightbox-initial-left-position', - originLeft + 'px' - ); - root.style.setProperty( - '--lightbox-initial-top-position', - originTop + 'px' - ); - root.style.setProperty( - '--lightbox-target-left-position', - targetLeft + 'px' - ); - root.style.setProperty( - '--lightbox-target-top-position', - targetTop + 'px' - ); + // As of this writing, using the calculations above will render the lightbox + // with a small, erroneous whitespace on the left side of the image in iOS Safari, + // perhaps due to an inconsistency in how browsers handle absolute positioning and CSS + // transformation. In any case, adding 1 pixel to the container width and height solves + // the problem, though this can be removed if the issue is fixed in the future. + styleTag.innerHTML = ` + :root { + --wp--lightbox-initial-top-position: ${ screenPosY }px; + --wp--lightbox-initial-left-position: ${ screenPosX }px; + --wp--lightbox-container-width: ${ containerWidth + 1 }px; + --wp--lightbox-container-height: ${ containerHeight + 1 }px; + --wp--lightbox-image-width: ${ lightboxImgWidth }px; + --wp--lightbox-image-height: ${ lightboxImgHeight }px; + --wp--lightbox-scale: ${ containerScale }; + } + `; +} + +function debounce( func, wait = 50 ) { + let timeout; + return () => { + const later = () => { + timeout = null; + func(); + }; + clearTimeout( timeout ); + timeout = setTimeout( later, wait ); + }; } diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index ce18b9599fbfbf..c9d7bbe464428a 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -885,40 +885,41 @@ test.describe( 'Image - interactivity', () => { await expect( lightbox ).toBeVisible(); - const document = page.getByRole( 'document' ); - const lightboxStyleCheck = await document.evaluate( ( doc ) => { - // We don't have access to the getPropertyValue() method - // on the CSSStyleDeclaration returned form getComputedStyle() - // in the Playwright outer context, so we need to evaluate it here - // in the browser context here. - const documentStyles = window.getComputedStyle( doc ); - const lightboxStyleVars = [ - '--lightbox-scale-width', - '--lightbox-scale-height', - '--lightbox-image-max-width', - '--lightbox-image-max-height', - '--lightbox-initial-left-position', - '--lightbox-initial-top-position', - ]; - const lightboxStyleErrors = []; - lightboxStyleVars.forEach( ( styleVar ) => { - if ( ! documentStyles.getPropertyValue( styleVar ) ) { - lightboxStyleErrors.push( styleVar ); - } - } ); - - return lightboxStyleErrors.length > 0 - ? lightboxStyleErrors - : true; + // Use page.evaluate to get the content of the style tag + const styleTagContent = await page.evaluate( () => { + const styleTag = document.querySelector( + 'style#wp-lightbox-styles' + ); + return styleTag ? styleTag.textContent : ''; } ); - expect( lightboxStyleCheck ).toBe( true ); + + // Define the keys you want to check for + const keysToCheck = [ + '--wp--lightbox-initial-top-position', + '--wp--lightbox-initial-left-position', + '--wp--lightbox-container-width', + '--wp--lightbox-container-height', + '--wp--lightbox-image-width', + '--wp--lightbox-image-height', + '--wp--lightbox-scale', + ]; + + // Check if all the keys are present in the style tag's content + const keysPresent = keysToCheck.every( ( key ) => + styleTagContent.includes( key ) + ); + + expect( keysPresent ).toBe( true ); const closeButton = lightbox.getByRole( 'button', { name: 'Close', } ); await closeButton.click(); - await expect( responsiveImage ).toHaveAttribute( 'src', '' ); + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); await expect( enlargedImage ).toHaveAttribute( 'src', imageUploadedSrc @@ -1013,7 +1014,10 @@ test.describe( 'Image - interactivity', () => { } ); await closeButton.click(); - await expect( responsiveImage ).toHaveAttribute( 'src', '' ); + await expect( responsiveImage ).toHaveAttribute( + 'src', + contentImageCurrentSrc + ); await expect( enlargedImage ).toHaveAttribute( 'src', imageUploadedSrc @@ -1362,7 +1366,7 @@ test.describe( 'Image - interactivity', () => { await page.getByRole( 'button', { name: 'Close' } ).click(); - await expect( responsiveImage ).toHaveAttribute( 'src', '' ); + await expect( responsiveImage ).toHaveAttribute( 'src', imgUrl ); await expect( enlargedImage ).toHaveAttribute( 'src', imgUrl ); } ); } );