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( `
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
}
}
}