Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/block-library/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Enhancements

- Image: Show the image caption inside the lightbox ("Enlarge on click"), for both single images and gallery images ([#81500](https://github.com/WordPress/gutenberg/pull/81500)).

### Bug Fixes

- Template Part: Show the "Default based on area" HTML element as selected instead of the `Select` placeholder ([#85172](https://github.com/WordPress/gutenberg/pull/85172)).
Expand Down
30 changes: 30 additions & 0 deletions packages/block-library/src/image/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,25 @@ function block_core_image_render_lightbox( $block_content, array $block, WP_Bloc
$figure_class_names = $processor->get_attribute( 'class' );
$figure_styles = $processor->get_attribute( 'style' );

/*
* Extract the caption so it can be shown inside the lightbox overlay.
*
* The lightbox duplicates the image into a shared overlay but not the
* surrounding markup, so the caption has to be carried over explicitly. The
* rendered content is read here (rather than the block's `caption`
* attribute) so that all sources are covered with one code path: single
* image blocks, static gallery images, and dynamic gallery images whose
* caption comes from the attachment's `post_excerpt`. By this point the
* render filter has already stripped an empty `<figcaption>`, so a match
* here is a real caption. `wp_kses_post()` keeps the stored value safe to
* inject as HTML regardless of what upstream filters produced; the same
* markup is already rendered visibly as the on-page caption.
*/
$caption = '';
if ( preg_match( '/<figcaption[^>]*>(.*)<\/figcaption>/s', $block_content, $caption_match ) ) {
$caption = wp_kses_post( $caption_match[1] );
}

// Create unique id and set the image metadata in the state.
$unique_image_id = uniqid();
wp_interactivity_state(
Expand All @@ -247,6 +266,7 @@ function block_core_image_render_lightbox( $block_content, array $block, WP_Bloc
'targetHeight' => $img_height,
'scaleAttr' => $block['attrs']['scale'] ?? false,
'alt' => $alt,
'caption' => $caption,
'galleryId' => $block_instance->context['galleryId'] ?? null,
'customAriaLabel' => $custom_aria_label ?? null,
'navigationButtonType' => $block_instance->context['navigationButtonType'] ?? 'icon',
Expand Down Expand Up @@ -370,6 +390,7 @@ class="wp-lightbox-overlay zoom"
data-wp-on--touchmove="actions.handleTouchMove"
data-wp-on--touchend="actions.handleTouchEnd"
data-wp-on--touchcancel="actions.handleTouchCancel"
data-wp-on--wheel="actions.handleCaptionWheel"
data-wp-on-window--resize="callbacks.setOverlayStyles"
data-wp-on-window--scroll="actions.handleScroll"
data-wp-bind--style="state.overlayStyles"
Expand Down Expand Up @@ -400,6 +421,15 @@ class="wp-lightbox-overlay zoom"
>
</figure>
</div>
<div class="wp-lightbox-caption-scroll" data-wp-bind--hidden="!state.selectedImage.caption">
<div class="wp-lightbox-caption-spacer" aria-hidden="true"></div>
<div class="wp-lightbox-caption-panel" style="background-color: {$background_color}">
<div
class="wp-element-caption wp-lightbox-caption"
data-wp-watch="callbacks.setCaption"
></div>
</div>
</div>
<button type="button" style="fill:{$close_button_color}" class="wp-lightbox-navigation-button wp-lightbox-navigation-button-next" data-wp-bind--hidden="!state.hasNavigation" data-wp-on--click="actions.showNextImage" data-wp-bind--aria-label="state.nextButtonAriaLabel">
<span class="wp-lightbox-navigation-text" data-wp-bind--hidden="!state.hasNavigationText">{$next_button_text}</span>
<span class="wp-lightbox-navigation-icon" data-wp-bind--hidden="!state.hasNavigationIcon">{$next_button_icon}</span>
Expand Down
73 changes: 73 additions & 0 deletions packages/block-library/src/image/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,79 @@
opacity: 0.9;
}

// The caption lives in a scroll layer laid over the (fixed) enlarged image,
// rather than inside `.lightbox-image-container`, which clips and transforms
// its contents during the zoom animation. The image never moves: a short
// caption sits in the empty space below it, and a long caption scrolls up
// and covers the image (scroll back up to reveal it again).
.wp-lightbox-caption-scroll {
position: absolute;
inset: 0;
// Above the image (2000001), below the navigation/close chrome (>=2000002).
z-index: 2000001;
overflow-y: auto;
// Keeps the scroll from chaining to the page once the caption ends.
overscroll-behavior: contain;
// This layer spans the whole overlay, so only the caption panel itself
// may take pointer events: everything else has to fall through to what
// it covers, or the layer would swallow the clicks that close the
// lightbox (handled by `.scrim`) and the swipe gestures that navigate a
// gallery (handled on the overlay). Scrolling the panel still scrolls
// this container, because a scroll travels up the scroll ancestry of
// the element under the finger rather than being hit-tested here.
pointer-events: none;

&[hidden] {
display: none;
}
}

// Transparent spacer that pushes the caption panel down to the image's
// bottom edge. The image is centered, so its bottom is `50vh + height/2`;
// the enlarged image now reserves extra safe area on every side (see
// `setOverlayStyles()`), so a short caption sits in the space below the
// image without covering it. `--wp--lightbox-container-height` is published
// by `setOverlayStyles()` and recomputed on resize, so the caption stays
// anchored responsively.
.wp-lightbox-caption-spacer {
height: calc(50vh + var(--wp--lightbox-container-height, 0px) / 2);
}

// Opaque panel painted with the overlay background, so a caption long enough
// to scroll fully covers the image as it moves up over it.
.wp-lightbox-caption-panel {
padding-block: 24px;
// Aligns the caption's left and right edges with the enlarged image
// above it. The image is centered and its width is published as
// `--wp--lightbox-container-width`, so its inset from each edge is half
// of the leftover space. Because that width is recomputed on resize,
// the caption follows the image at every breakpoint — the two are
// separate elements, so a fixed padding could only match at one size.
// The `max()` floor keeps a sane gutter in the brief moment before
// `setOverlayStyles()` publishes the variable.
padding-inline: max(16px, calc((100% - var(--wp--lightbox-container-width, 100%)) / 2));
cursor: default;
// Re-enables pointer events disabled on the scroll layer above, so the
// caption can be scrolled, selected, and its links followed.
pointer-events: auto;
}

.wp-lightbox-caption {
margin: 0 auto;
// Inherit the theme's content width (theme.json
// `settings.layout.contentSize`), so theme developers control the
// caption measure with the same knob as post content. Capped at 90vw
// so it never overflows a narrow viewport. The fallback (used by
// classic themes and block themes that set no content size, where the
// variable is not emitted) keeps the measure readable.
max-width: min(90vw, var(--wp--style--global--content-size, 480px));

// Do not set `text-align` here. The element carries the
// `wp-element-caption` class, so caption alignment defined in
// `theme.json` (`styles.elements.caption`) — which targets
// `.wp-element-caption` — is inherited automatically.
}

// When fading, make the image come in slightly slower
// or faster than the scrim to give a sense of depth.
&.active {
Expand Down
99 changes: 99 additions & 0 deletions packages/block-library/src/image/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ const COMMIT_DISTANCE_RATIO = 0.2;
const FLICK_DISTANCE_PX = 50;
const FLICK_MAX_DURATION_MS = 300;

// Assumed line height when a wheel reports its delta in lines rather than
// pixels. Only some mice and browsers do, and the exact value matters little:
// it decides how far one notch of such a wheel moves the caption.
const WHEEL_LINE_HEIGHT_PX = 16;

/**
* The distance a wheel event asks to scroll, in pixels.
*
* `deltaY` is in pixels for trackpads and most mice, but some report lines or
* pages instead, while `scrollTop` is only ever pixels.
*
* @param {WheelEvent} event The wheel event.
* @param {Element} scroller The element being scrolled.
* @return {number} The distance in pixels.
*/
function getWheelDeltaPx( event, scroller ) {
// `WheelEvent.DOM_DELTA_LINE` and `WheelEvent.DOM_DELTA_PAGE`, spelled out
// because `WheelEvent` is not among this file's known globals.
if ( event.deltaMode === 1 ) {
return event.deltaY * WHEEL_LINE_HEIGHT_PX;
}
if ( event.deltaMode === 2 ) {
return event.deltaY * scroller.clientHeight;
}
return event.deltaY;
}

/**
* How far a committed swipe travels before the image is swapped.
*
Expand Down Expand Up @@ -429,6 +456,21 @@ const { state, actions, callbacks } = store(
return;
}

// A gesture that starts on the caption belongs to the caption,
// so it is left to scroll natively: the `preventDefault()`
// below would otherwise stop the caption from scrolling at all
// on touch devices, and the swipe tracking that follows would
// drag the image sideways while the finger is reading. Touch
// events keep the target they started on for the whole gesture,
// so one test here covers the whole scroll. The caption layer
// sets `overscroll-behavior: contain`, which keeps the page
// behind from scrolling once the caption reaches its end.
if (
event.target?.closest?.( '.wp-lightbox-caption-scroll' )
) {
return;
}

// Prevents triggering the scroll event because otherwise the page
// jumps around when it resets the scroll position. This also means
// that closing the lightbox requires a simple tap. May change in
Expand Down Expand Up @@ -489,6 +531,35 @@ const { state, actions, callbacks } = store(
stopDragAnimations();
resetTouchDrag();
},
handleCaptionWheel: withSyncEvent( ( event ) => {
if ( ! state.overlayEnabled || event.ctrlKey ) {
return;
}

const scroller = event.currentTarget.querySelector(
'.wp-lightbox-caption-scroll'
);
if ( ! scroller || scroller.hidden ) {
return;
}

// A wheel over the caption panel already scrolls the caption,
// so it is left to do that natively, smoothing and all. The
// panel only begins below the enlarged image, though, and the
// image fills most of the overlay, so the pointer is usually
// somewhere the caption layer takes no pointer events. There
// the wheel would scroll the page behind instead, which
// `handleScroll()` immediately yanks back, leaving the caption
// unreachable. Carry those deltas over to the caption by hand.
if ( event.target.closest( '.wp-lightbox-caption-scroll' ) ) {
return;
}

scroller.scrollTop += getWheelDeltaPx( event, scroller );
// Without this the page behind still scrolls underneath the
// overlay, even though the caption is what moved.
event.preventDefault();
} ),
handleTouchCancel() {
// The gesture was interrupted — the browser cancelled the drag, or
// a system gesture took over — so no `touchend` will arrive to
Expand Down Expand Up @@ -804,6 +875,15 @@ const { state, actions, callbacks } = store(
verticalPadding = 80;
}

// Adds extra breathing room on every side of the enlarged image
// so a caption has room to sit below it without covering it.
// Applied to every image (not only captioned ones) so navigating
// a gallery never resizes the image. `padding` here is the total
// removed from each axis, so the per-side inset is half of it.
const imageSafeArea = 24;
horizontalPadding += imageSafeArea * 2;
verticalPadding += imageSafeArea * 2;

const targetMaxWidth = Math.min(
window.innerWidth - horizontalPadding,
containerWidth
Expand Down Expand Up @@ -1000,6 +1080,25 @@ const { state, actions, callbacks } = store(
const { ref } = getElement();
state.metadata[ imageId ].buttonRef = ref;
},
setCaption() {
// The caption is injected imperatively rather than through a
// `data-wp-bind--innerHTML` binding: Preact reconciles the
// element's children and won't apply a plain `innerHTML` prop,
// so a binding leaves the node empty. This runs in a
// `data-wp-watch`, so it re-applies whenever the selected image
// (and therefore its caption) changes while navigating a
// gallery. The value was sanitized with `wp_kses_post()` on the
// server and is the same markup already shown as the on-page
// caption.
const { ref } = getElement();
if ( ! ref ) {
return;
}
const caption = state.selectedImage?.caption || '';
if ( ref.innerHTML !== caption ) {
ref.innerHTML = caption;
}
},
},
},
{ lock: true }
Expand Down
Loading