diff --git a/lib/block-supports/behaviors.php b/lib/block-supports/behaviors.php index 95639178c2de4..b6692195eb5fb 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 e6816e873b776..c7eec224f6587 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 e0fd68646964b..53a2d1f7d567f 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 ce18b9599fbfb..c9d7bbe464428 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 ); } ); } );