@wordpress/block-library
Version:
Block library for the WordPress editor.
437 lines (421 loc) • 14.4 kB
JavaScript
import { __, sprintf } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import {
Button,
Notice,
PanelBody,
Placeholder,
SelectControl,
Spinner,
ToolbarButton,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalConfirmDialog as ConfirmDialog,
} from '@wordpress/components';
import {
BlockContextProvider,
BlockControls,
useBlockEditingMode,
__experimentalUseBlockPreview as useBlockPreview,
} from '@wordpress/block-editor';
import { sharedIcon } from './shared-icon';
import { Caption } from '../utils/caption';
import { DEFAULT_ORDERBY, DEFAULT_ORDER, MAX_IMAGES } from './dynamic-source';
/**
* Ordering options for a dynamic gallery source. Each value is a composite
* `"orderby/order"` string mapping to the matching `/wp/v2/media` collection
* params. `menu_order` is deliberately omitted — it isn't a valid REST `orderby`
* value, so the editor preview couldn't reproduce it (see `dynamic-source.js`).
*/
const ORDER_OPTIONS = [
{ label: __( 'Newest to oldest' ), value: 'date/desc' },
{ label: __( 'Oldest to newest' ), value: 'date/asc' },
{
/* translators: Label for ordering images by title in ascending order. */
label: __( 'A → Z' ),
value: 'title/asc',
},
{
/* translators: Label for ordering images by title in descending order. */
label: __( 'Z → A' ),
value: 'title/desc',
},
];
/**
* "Order by" control for a dynamic gallery, mirroring the Query Loop block's
* `OrderControl`: a single `SelectControl` whose value composites `orderby` and
* `order`, split apart again on change.
*
* @param {Object} props
* @param {string} props.orderby Current `orderby` value.
* @param {string} props.order Current `order` value (`asc`/`desc`).
* @param {Function} props.onChange Called with `{ orderby, order }` on change.
*/
function OrderControl( { orderby, order, onChange } ) {
return (
<SelectControl
label={ __( 'Order by' ) }
value={ `${ orderby }/${ order }` }
options={ ORDER_OPTIONS }
onChange={ ( value ) => {
const [ newOrderby, newOrder ] = value.split( '/' );
onChange( { orderby: newOrderby, order: newOrder } );
} }
/>
);
}
/**
* Confirmation for leaving dynamic mode, shown from both the block toolbar and
* the Source panel so the two entry points explain the change identically.
*
* Detaching keeps the images the gallery currently shows but breaks the link to
* its source, so it's worth confirming — mirroring the dialog `GallerySourcePanel`
* shows for the opposite direction.
*
* @param {Object} props
* @param {Function} props.onConfirm Called when the user confirms detaching.
* @param {Function} props.onCancel Called when the user dismisses the dialog.
*/
function DetachGalleryDialog( { onConfirm, onCancel } ) {
return (
<ConfirmDialog
isOpen
title={ __( 'Detach Gallery' ) }
__experimentalHideHeader={ false }
confirmButtonText={ __( 'Detach' ) }
onConfirm={ onConfirm }
onCancel={ onCancel }
size="medium"
>
{ __(
'The gallery displays the images attached to the post. Detaching will enable you to add, delete, or reorder images. However, new attachments will no longer be added automatically.'
) }
</ConfirmDialog>
);
}
/**
* The Gallery block's "Source" inspector panel.
*
* In dynamic mode it shows the resolved source, a control to detach the gallery
* from it, and the source ordering. In static mode it offers the entry point
* into dynamic mode. Either direction is a one-way change, so both are behind a
* confirmation dialog this panel owns. Rendered inside the block's
* `InspectorControls`, alongside the Settings panel.
*
* @param {Object} props
* @param {Object} props.dynamic The `useDynamicGallery` result.
* @param {Object} props.dropdownMenuProps Shared ToolsPanel dropdown menu props.
* @param {boolean} props.hasImages Whether the gallery has manually-added images.
*/
export function GallerySourcePanel( {
dynamic,
dropdownMenuProps,
hasImages,
} ) {
const {
dynamicContent,
canUseDynamicSource,
sourceDescriptor,
sourceOrderby,
sourceOrder,
setSourceOrder,
convertToStatic,
enableDynamicMode,
resetSource,
isResolvingDynamic,
hasMoreImagesThanCap,
dynamicMediaTotal,
} = dynamic;
const isDynamic = !! dynamicContent;
const [ isConfirming, setIsConfirming ] = useState( false );
const [ isConfirmingDetach, setIsConfirmingDetach ] = useState( false );
// Entering dynamic mode discards any hand-added images, so confirm first
// when there are images to lose; otherwise switch straight away.
function requestEnableDynamicMode() {
if ( hasImages ) {
setIsConfirming( true );
} else {
enableDynamicMode();
}
}
if ( isDynamic ) {
return (
<>
<ToolsPanel
label={ __( 'Source' ) }
resetAll={ resetSource }
dropdownMenuProps={ dropdownMenuProps }
>
<div className="wp-block-gallery__source-settings">
<p className="wp-block-gallery__source-description">
{ sourceDescriptor?.description ??
__( 'Dynamic images.' ) }
</p>
<Button
__next40pxDefaultSize
variant="secondary"
onClick={ () => setIsConfirmingDetach( true ) }
// Guard the race where the media is still resolving:
// detaching now would map over an incomplete (or
// empty) list and produce a gallery missing images.
disabled={ isResolvingDynamic }
accessibleWhenDisabled
>
{ __( 'Detach Gallery' ) }
</Button>
</div>
{ hasMoreImagesThanCap && (
<Notice
className="wp-block-gallery__source-notice"
status="warning"
isDismissible={ false }
>
{ sprintf(
/* translators: 1: number of images shown. 2: total number of matching images. */
__(
'Only the first %1$d of %2$d images will be displayed.'
),
MAX_IMAGES,
dynamicMediaTotal
) }
</Notice>
) }
<ToolsPanelItem
isShownByDefault
label={ __( 'Order by' ) }
hasValue={ () =>
sourceOrderby !== DEFAULT_ORDERBY ||
sourceOrder !== DEFAULT_ORDER
}
onDeselect={ () =>
setSourceOrder( undefined, undefined )
}
>
<OrderControl
orderby={ sourceOrderby }
order={ sourceOrder }
onChange={ ( { orderby, order } ) =>
setSourceOrder( orderby, order )
}
/>
</ToolsPanelItem>
</ToolsPanel>
{ isConfirmingDetach && (
<DetachGalleryDialog
onConfirm={ () => {
convertToStatic();
setIsConfirmingDetach( false );
} }
onCancel={ () => setIsConfirmingDetach( false ) }
/>
) }
</>
);
}
// In static mode this panel is just an entry into dynamic mode, so hide it
// when there's no post type to preview against. This is intentionally
// stricter than the placeholder's entry button (see `edit.js`), which stays
// available anywhere because the source resolves at render time.
if ( ! canUseDynamicSource ) {
return null;
}
return (
<>
<PanelBody title={ __( 'Source' ) }>
<div className="wp-block-gallery__source-settings">
{ /*
* Hardcoded on purpose: this single-source entry button (and
* its confirm dialog below) is temporary. Once more sources
* exist it becomes a "Choose source" select whose options read
* from each source descriptor's `title`, with help text
* carrying the per-source explanation this string does today.
*/ }
<Button
__next40pxDefaultSize
variant="secondary"
onClick={ requestEnableDynamicMode }
>
{ __( 'Use images attached to the post' ) }
</Button>
</div>
</PanelBody>
{ isConfirming && (
<ConfirmDialog
isOpen
title={ __( 'Use images attached to the post?' ) }
__experimentalHideHeader={ false }
confirmButtonText={ __( 'Use attached images' ) }
onConfirm={ () => {
enableDynamicMode();
setIsConfirming( false );
} }
onCancel={ () => setIsConfirming( false ) }
size="medium"
>
{ __(
'The images in this gallery will be replaced, but will remain in the media library.'
) }
</ConfirmDialog>
) }
</>
);
}
/**
* Renders the resolved image blocks as a read-only preview.
*
* `useBlockPreview` returns a `useDisabled` ref that makes its subtree inert, so
* previewed images (including any links) aren't interactive in the editor. The
* ref needs a real element, yet the images must stay flex children of the gallery
* `<figure>` and sit beside an editable caption. `display: contents` resolves
* this: the wrapper carries the ref but generates no box, so the image figures
* remain the figure's flex items and only they are disabled — the caption sibling
* stays editable. This relies on the gallery's image styles using descendant
* (not direct-child) selectors, which the box-less wrapper leaves intact.
*
* @param {Object} props
* @param {Object[]} props.imageBlocks Non-persisted `core/image` blocks to preview.
*/
function GalleryImagesPreview( { imageBlocks } ) {
const { children, ref, className } = useBlockPreview( {
blocks: imageBlocks,
} );
return (
<div
ref={ ref }
className={ className }
style={ { display: 'contents' } }
>
{ children }
</div>
);
}
/**
* Renders a dynamic-mode gallery on the canvas:
*
* - a block-toolbar control to detach the gallery from its source, confirmed in
* a dialog;
* - the gallery `<figure>` wrapper holding a non-editable preview of the
* resolved media (or a placeholder while resolving / when nothing is found),
* with the gallery's provided context so the previewed images inherit
* gallery-wide settings;
* - an editable gallery-level caption, alongside the read-only preview;
* - the (empty) inner blocks kept mounted so the container's `allowedBlocks: []`
* keeps syncing to block list settings (which blocks insertion and hides the
* List View).
*
* @param {Object} props
* @param {Object} props.dynamic The `useDynamicGallery` result.
* @param {Object} props.blockProps The gallery's `useBlockProps()` result.
* @param {Object} props.innerBlocksProps The gallery's `useInnerBlocksProps()` result.
* @param {Object} props.attributes The gallery block attributes.
* @param {Function} props.setAttributes The block's `setAttributes`.
* @param {boolean} props.isSelected Whether the gallery block is selected.
* @param {Function} props.insertBlocksAfter Inserts blocks after the gallery.
* @param {boolean} props.isContentLocked Whether the gallery is content-locked.
* @param {boolean} props.multiGallerySelection Whether multiple galleries are selected.
*/
export function GalleryDynamicView( {
dynamic,
blockProps,
innerBlocksProps,
attributes,
setAttributes,
isSelected,
insertBlocksAfter,
isContentLocked,
multiGallerySelection,
} ) {
const {
sourceDescriptor,
dynamicImageBlocks,
galleryContext,
isResolvingDynamic,
convertToStatic,
} = dynamic;
// Detaching the gallery materializes editable inner blocks, which is a
// structural change. Only offer it when the block is fully editable:
// under a content lock (e.g. inside a `contentOnly` group) the editing mode
// is `'contentOnly'`/`'disabled'`, where structural toolbar controls are
// hidden and the conversion shouldn't be possible.
const blockEditingMode = useBlockEditingMode();
const [ isConfirmingDetach, setIsConfirmingDetach ] = useState( false );
// Empty-state copy for the preview. Framed as forward-looking ("… will appear
// here") rather than as an error, since the same empty result covers both a
// post with no matching images and a template with no post in context yet —
// in either case the source simply resolves to nothing right now. The per-
// source wording comes from the source descriptor.
const emptyInstructions = isResolvingDynamic
? __( 'Loading images…' )
: sourceDescriptor?.emptyMessage ??
__( 'Dynamic images will appear here.' );
return (
<>
{ blockEditingMode === 'default' && (
<>
<BlockControls group="other">
<ToolbarButton
onClick={ () => setIsConfirmingDetach( true ) }
// Same guard as the inspector's "Detach Gallery": both end in
// `convertToStatic`, which would map over a
// still-resolving (or empty) media list.
// (`ToolbarButton` stays focusable when disabled by
// default.)
disabled={ isResolvingDynamic }
>
{ __( 'Detach' ) }
</ToolbarButton>
</BlockControls>
{ isConfirmingDetach && (
<DetachGalleryDialog
onConfirm={ () => {
convertToStatic();
setIsConfirmingDetach( false );
} }
onCancel={ () => setIsConfirmingDetach( false ) }
/>
) }
</>
) }
<figure { ...blockProps }>
{ dynamicImageBlocks.length ? (
<BlockContextProvider value={ galleryContext }>
<GalleryImagesPreview
imageBlocks={ dynamicImageBlocks }
/>
</BlockContextProvider>
) : (
<Placeholder
icon={ sharedIcon }
label={ __( 'Gallery' ) }
instructions={ emptyInstructions }
>
{ isResolvingDynamic && <Spinner /> }
</Placeholder>
) }
<Caption
attributes={ attributes }
setAttributes={ setAttributes }
isSelected={ isSelected }
insertBlocksAfter={ insertBlocksAfter }
showToolbarButton={
! multiGallerySelection && ! isContentLocked
}
className="blocks-gallery-caption"
label={ __( 'Gallery caption text' ) }
placeholder={ __( 'Add gallery caption' ) }
/>
</figure>
{ /*
* Dynamic mode shows a preview instead of real inner blocks, but the
* empty inner blocks are still rendered here for their side effect:
* the `allowedBlocks: []` passed to `useInnerBlocksProps` only syncs
* to block list settings while the inner blocks are mounted (via
* `useNestedSettingsUpdate`). That setting is what blocks insertion
* (`canInsertBlockType`) and hides the now-unusable List View
* (`shouldRenderBlockListView`). With no inner blocks and no appender,
* this renders no output of its own.
*/ }
{ innerBlocksProps.children }
</>
);
}