diff --git a/lib/block-supports/behaviors.php b/lib/block-supports/behaviors.php index 55a3419e466fb..27e5faeb8e8a4 100644 --- a/lib/block-supports/behaviors.php +++ b/lib/block-supports/behaviors.php @@ -48,18 +48,18 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $link_destination = isset( $block['attrs']['linkDestination'] ) ? $block['attrs']['linkDestination'] : 'none'; // Get the lightbox setting from the block attributes. if ( isset( $block['attrs']['behaviors']['lightbox'] ) ) { - $lightbox = $block['attrs']['behaviors']['lightbox']; + $lightbox_settings = $block['attrs']['behaviors']['lightbox']; // If the lightbox setting is not set in the block attributes, get it from the theme.json file. } else { $theme_data = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data()->get_data(); if ( isset( $theme_data['behaviors']['blocks'][ $block['blockName'] ]['lightbox'] ) ) { - $lightbox = $theme_data['behaviors']['blocks'][ $block['blockName'] ]['lightbox']; + $lightbox_settings = $theme_data['behaviors']['blocks'][ $block['blockName'] ]['lightbox']; } else { - $lightbox = false; + $lightbox_settings = null; } } - if ( ! $lightbox || 'none' !== $link_destination || empty( $experiments['gutenberg-interactivity-api-core-blocks'] ) ) { + if ( ! $lightbox_settings || 'none' !== $link_destination || empty( $experiments['gutenberg-interactivity-api-core-blocks'] ) ) { return $block_content; } @@ -75,11 +75,28 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { } $content = $processor->get_updated_html(); + $lightbox_animation = ''; + if ( isset( $lightbox_settings['animation'] ) ) { + $lightbox_animation = $lightbox_settings['animation']; + } + + // We want to store the src in the context so we can set it dynamically when the lightbox is opened. + $z = new WP_HTML_Tag_Processor( $content ); + $z->next_tag( 'img' ); + if ( isset( $block['attrs']['id'] ) ) { + $img_src = wp_get_attachment_url( $block['attrs']['id'] ); + } else { + $img_src = $z->get_attribute( 'src' ); + } + $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', '{ "core": { "image": { "initialized": false, "lightboxEnabled": false } } }' ); + $w->set_attribute( + 'data-wp-context', + sprintf( '{ "core":{ "image": { "initialized": false, "imageSrc": "%s", "lightboxEnabled": false, "lightboxAnimation": "%s", "hideAnimationEnabled": false } } }', $img_src, $lightbox_animation ) + ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. @@ -91,15 +108,9 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { ''; $body_content = preg_replace( '/]+>/', $button, $body_content ); - // Add directive to expand modal image if appropriate. + // Add src to the modal image. $m = new WP_HTML_Tag_Processor( $content ); $m->next_tag( 'img' ); - if ( isset( $block['attrs']['id'] ) ) { - $img_src = wp_get_attachment_url( $block['attrs']['id'] ); - } else { - $img_src = $m->get_attribute( 'src' ); - } - $m->set_attribute( 'data-wp-context', '{ "core": { "image": { "imageSrc": "' . $img_src . '"} } }' ); $m->set_attribute( 'data-wp-bind--src', 'selectors.core.image.imageSrc' ); $modal_content = $m->get_updated_html(); @@ -111,11 +122,12 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $close_button_label = esc_attr__( 'Close', 'gutenberg' ); $lightbox_html = << - hasBlockSupport( blockName, 'behaviors.' + behaviorName ) && + hasBlockSupport( blockName, `behaviors.${ behaviorName }` ) && behaviorValue ) // Filter out behaviors that are disabled. .map( ( [ behaviorName ] ) => ( { value: behaviorName, - label: - // Capitalize the first letter of the behavior name. - behaviorName[ 0 ].toUpperCase() + - behaviorName.slice( 1 ).toLowerCase(), + // Capitalize the first letter of the behavior name. + label: `${ behaviorName.charAt( 0 ).toUpperCase() }${ behaviorName + .slice( 1 ) + .toLowerCase() }`, } ) ); - // If every behavior is disabled, do not show the behaviors inspector control. - if ( behaviorsOptions.length === 0 ) return null; - - const options = [ noBehaviorsOption, ...behaviorsOptions ]; + const options = [ + ...Object.values( defaultBehaviors ), + ...behaviorsOptions, + ]; + // If every behavior is disabled, do not show the behaviors inspector control. + if ( behaviorsOptions.length === 0 ) { + return null; + } // Block behaviors take precedence over theme behaviors. - const behaviors = merge( themeBehaviors, blockBehaviors || {} ); + const behaviors = { ...themeBehaviors, ...( blockBehaviors || {} ) }; const helpText = disabled ? __( 'The lightbox behavior is disabled for linked images.' ) - : __( 'Add behaviors.' ); + : ''; + + const value = () => { + if ( blockBehaviors === undefined ) { + return 'default'; + } + if ( behaviors?.lightbox.enabled ) { + return 'lightbox'; + } + return ''; + }; return ( @@ -81,24 +93,37 @@ function BehaviorsControl( { + { behaviors?.lightbox.enabled && ( + + ) } - - - ); } @@ -129,8 +154,8 @@ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { { - if ( nextValue === undefined ) { + onChangeBehavior={ ( nextValue ) => { + if ( nextValue === 'default' ) { props.setAttributes( { behaviors: undefined, } ); @@ -139,11 +164,29 @@ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { // change the default value (true) so we save it in the attributes. props.setAttributes( { behaviors: { - lightbox: nextValue === 'lightbox', + lightbox: { + enabled: nextValue === 'lightbox', + animation: + nextValue === 'lightbox' + ? 'zoom' + : '', + }, }, } ); } } } + onChangeAnimation={ ( nextValue ) => { + props.setAttributes( { + behaviors: { + lightbox: { + enabled: + props.attributes.behaviors.lightbox + .enabled, + animation: nextValue, + }, + }, + } ); + } } disabled={ blockHasLink } /> diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js index 552bdf13a66ca..2ef370496a894 100644 --- a/packages/block-library/src/image/interactivity.js +++ b/packages/block-library/src/image/interactivity.js @@ -21,28 +21,70 @@ store( { actions: { core: { image: { - showLightbox: ( { context } ) => { + showLightbox: ( { context, event } ) => { context.core.image.initialized = true; - context.core.image.lightboxEnabled = true; context.core.image.lastFocusedElement = window.document.activeElement; - context.core.image.scrollPosition = window.scrollY; - document.documentElement.classList.add( - 'has-lightbox-open' - ); + context.core.image.scrollDelta = 0; + + // 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 + const imgDom = document.createElement( 'img' ); + + imgDom.onload = function () { + // Enable the lightbox only after the image + // is loaded to prevent flashing of unstyled content + context.core.image.lightboxEnabled = true; + if ( context.core.image.lightboxAnimation === 'zoom' ) { + setZoomStyles( imgDom, 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' + ); + }; + imgDom.setAttribute( 'src', context.core.image.imageSrc ); }, 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.type === 'mousewheel' && - Math.abs( - window.scrollY - - context.core.image.scrollPosition - ) < 5 + 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' ) { - return; + // 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 ); } + document.documentElement.classList.remove( 'has-lightbox-open' ); @@ -101,6 +143,9 @@ store( { core: { image: { 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 ); @@ -116,3 +161,100 @@ store( { }, }, } ); + +function setZoomStyles( imgDom, context, event ) { + let targetWidth = imgDom.naturalWidth; + let targetHeight = imgDom.naturalHeight; + + const verticalPadding = 40; + + // As per the design, let's allow the image to stretch + // to the full width of its containing figure, but for the height, + // constrain it with a fixed padding + const containerWidth = context.core.image.figureRef.clientWidth; + + // The lightbox image has `positione:absolute` and + // ignores its parent's padding, so let's set the padding here, + // to be used when calculating the image width and positioning + let horizontalPadding = 0; + if ( containerWidth > 480 ) { + horizontalPadding = 40; + } else if ( containerWidth > 1920 ) { + horizontalPadding = 80; + } + + const containerHeight = + context.core.image.figureRef.clientHeight - verticalPadding * 2; + + // Check difference between the image and figure dimensions + const widthOverflow = Math.abs( + Math.min( containerWidth - targetWidth, 0 ) + ); + const heightOverflow = Math.abs( + Math.min( containerHeight - targetHeight, 0 ) + ); + + // If image is larger than its container any dimension, resize along its largest axis. + // For vertically oriented devices, always maximize the width. + if ( widthOverflow > 0 || heightOverflow > 0 ) { + if ( + widthOverflow >= heightOverflow || + containerHeight >= containerWidth + ) { + targetWidth = containerWidth - horizontalPadding * 2; + targetHeight = + imgDom.naturalHeight * ( targetWidth / imgDom.naturalWidth ); + } else { + targetHeight = containerHeight; + targetWidth = + imgDom.naturalWidth * ( targetHeight / imgDom.naturalHeight ); + } + } + + // 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; + + // Get values used to center the image + let targetLeft = 0; + if ( targetWidth >= containerWidth ) { + targetLeft = horizontalPadding; + } else { + targetLeft = ( containerWidth - targetWidth ) / 2; + } + let targetTop = 0; + if ( targetHeight >= containerHeight ) { + targetTop = verticalPadding; + } else { + targetTop = ( containerHeight - targetHeight ) / 2 + verticalPadding; + } + + 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' + ); +} diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 6de2bdd689859..563a91ad340b3 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -204,7 +204,6 @@ justify-content: center; align-items: center; flex-direction: column; - padding: 30px; figcaption { display: none; @@ -231,24 +230,69 @@ opacity: 0.9; } - &.initialized { - animation: both turn-off-visibility 300ms; + &.fade { + &.active { + visibility: visible; + animation: both turn-on-visibility 0.25s; + 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; + } + } + } + } + + &.zoom { img { - animation: both turn-off-visibility 250ms; + position: absolute; + transform-origin: top left; + width: var(--lightbox-image-max-width); + height: var(--lightbox-image-max-height); } &.active { + opacity: 1; visibility: visible; - animation: both turn-on-visibility 250ms; + .wp-block-image img { + animation: lightbox-zoom-in 0.4s forwards; - img { - animation: both turn-on-visibility 300ms; + @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; + } + } + .scrim { + animation: turn-off-visibility 0.4s forwards; + } } } } } +html.has-lightbox-open { + overflow: hidden; +} + @keyframes turn-on-visibility { 0% { opacity: 0; @@ -273,6 +317,32 @@ } } -html.has-lightbox-open { - overflow: hidden; +@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)); + } + 100% { + left: var(--lightbox-target-left-position); + top: var(--lightbox-target-top-position); + transform: 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); + } + 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)); + } } diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 2516a6d9c5cea..ad07a9b3914fd 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -811,7 +811,12 @@ test.describe( 'Image - interactivity', () => { let blocks = await editor.getBlocks(); expect( blocks[ 0 ].attributes ).toMatchObject( { - behaviors: { lightbox: true }, + behaviors: { + lightbox: { + animation: 'zoom', + enabled: true, + }, + }, linkDestination: 'none', } ); expect( blocks[ 0 ].attributes.url ).toContain( filename ); @@ -819,7 +824,12 @@ test.describe( 'Image - interactivity', () => { await page.getByLabel( 'Behaviors' ).selectOption( '' ); blocks = await editor.getBlocks(); expect( blocks[ 0 ].attributes ).toMatchObject( { - behaviors: { lightbox: false }, + behaviors: { + lightbox: { + animation: '', + enabled: false, + }, + }, linkDestination: 'none', } ); expect( blocks[ 0 ].attributes.url ).toContain( filename ); diff --git a/test/e2e/specs/editor/various/behaviors.spec.js b/test/e2e/specs/editor/various/behaviors.spec.js index dc03dd166b001..9fe1fedf175c3 100644 --- a/test/e2e/specs/editor/various/behaviors.spec.js +++ b/test/e2e/specs/editor/various/behaviors.spec.js @@ -49,43 +49,6 @@ test.describe( 'Testing behaviors functionality', () => { await page.waitForLoadState(); } ); - test( '`No Behaviors` should be the default as defined in the core theme.json', async ( { - admin, - editor, - requestUtils, - page, - behaviorUtils, - } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - await admin.createNewPost(); - const media = await behaviorUtils.createMedia(); - await editor.insertBlock( { - name: 'core/image', - attributes: { - alt: filename, - id: media.id, - url: media.source_url, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - const editorSettings = page.getByRole( 'region', { - name: 'Editor settings', - } ); - await editorSettings - .getByRole( 'button', { name: 'Advanced' } ) - .click(); - const select = editorSettings.getByRole( 'combobox', { - name: 'Behavior', - } ); - - // By default, no behaviors should be selected. - await expect( select ).toHaveValue( '' ); - - // By default, you should be able to select the Lightbox behavior. - await expect( select.getByRole( 'option' ) ).toHaveCount( 2 ); - } ); - test( 'Behaviors UI can be disabled in the `theme.json`', async ( { admin, editor, @@ -143,7 +106,12 @@ test.describe( 'Testing behaviors functionality', () => { id: media.id, url: media.source_url, // Explicitly set the value for behaviors to true. - behaviors: { lightbox: true }, + behaviors: { + lightbox: { + enabled: true, + animation: 'zoom', + }, + }, }, } ); @@ -162,8 +130,8 @@ test.describe( 'Testing behaviors functionality', () => { // attributes takes precedence over the theme's value. await expect( select ).toHaveValue( 'lightbox' ); - // There should be 2 options available: `No behaviors` and `Lightbox`. - await expect( select.getByRole( 'option' ) ).toHaveCount( 2 ); + // There should be 3 options available: `No behaviors` and `Lightbox`. + await expect( select.getByRole( 'option' ) ).toHaveCount( 3 ); // We can change the value of the behaviors dropdown to `No behaviors`. await select.selectOption( { label: 'No behaviors' } ); @@ -173,50 +141,6 @@ test.describe( 'Testing behaviors functionality', () => { // lightbox even though the theme.json has it set to false. } ); - test( 'You can set the default value for the behaviors in the theme.json', async ( { - admin, - editor, - requestUtils, - page, - behaviorUtils, - } ) => { - // In this theme, the default value for settings.behaviors.blocks.core/image.lightbox is `true`. - await requestUtils.activateTheme( 'behaviors-enabled' ); - await admin.createNewPost(); - const media = await behaviorUtils.createMedia(); - - await editor.insertBlock( { - name: 'core/image', - attributes: { - alt: filename, - id: media.id, - url: media.source_url, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - const editorSettings = page.getByRole( 'region', { - name: 'Editor settings', - } ); - await editorSettings - .getByRole( 'button', { name: 'Advanced' } ) - .click(); - const select = editorSettings.getByRole( 'combobox', { - name: 'Behavior', - } ); - - // The behaviors dropdown should be present and the value should be set to - // `lightbox`. - await expect( select ).toHaveValue( 'lightbox' ); - - // There should be 2 options available: `No behaviors` and `Lightbox`. - await expect( select.getByRole( 'option' ) ).toHaveCount( 2 ); - - // We can change the value of the behaviors dropdown to `No behaviors`. - await select.selectOption( { label: 'No behaviors' } ); - await expect( select ).toHaveValue( '' ); - } ); - test( 'Lightbox behavior is disabled if the Image has a link', async ( { admin, editor, @@ -254,7 +178,7 @@ test.describe( 'Testing behaviors functionality', () => { await expect( select ).toBeDisabled(); } ); - test( 'Lightbox behavior control has a Reset button that removes the markup', async ( { + test( 'Lightbox behavior control has a default option that removes the markup', async ( { admin, editor, requestUtils, @@ -293,13 +217,11 @@ test.describe( 'Testing behaviors functionality', () => { .last() .click(); - const resetButton = editorSettings.getByRole( 'button', { - name: 'Reset', + const select = editorSettings.getByRole( 'combobox', { + name: 'Behavior', } ); - expect( resetButton ).toBeDefined(); - - await resetButton.last().click(); + await select.selectOption( { label: 'Default' } ); expect( await editor.getEditedPostContent() ) .toBe( `
1024x768_e2e_test_image_size.jpeg
diff --git a/test/gutenberg-test-themes/behaviors-enabled/theme.json b/test/gutenberg-test-themes/behaviors-enabled/theme.json index f49129622d9f6..8e7ce39023fd3 100644 --- a/test/gutenberg-test-themes/behaviors-enabled/theme.json +++ b/test/gutenberg-test-themes/behaviors-enabled/theme.json @@ -3,7 +3,10 @@ "behaviors": { "blocks": { "core/image": { - "lightbox": true + "lightbox": { + "enabled": true, + "animation": "zoom" + } } } } diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json index a9f920f6dd0ab..cc4b0882fd22c 100644 --- a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json +++ b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json @@ -3,9 +3,7 @@ "settings": { "blocks": { "core/image": { - "behaviors": { - "lightbox": false - } + "behaviors": false } } }