import clsx from 'clsx';
import {
Button,
DropZone,
Icon as WCIcon,
Spinner as WCSpinner,
__experimentalText as WCText,
__experimentalTruncate as Truncate,
__experimentalVStack as VStack,
__experimentalHStack as HStack,
BaseControl,
withFilters,
} from '@wordpress/components';
import { isBlobURL, getBlobTypeByURL } from '@wordpress/blob';
import { store as coreStore, type Attachment } from '@wordpress/core-data';
import { useSelect, useDispatch } from '@wordpress/data';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import {
archive,
audio,
video,
file,
closeSmall,
chevronUp,
chevronDown,
chevronLeft,
chevronRight,
} from '@wordpress/icons';
import {
Stack,
Tooltip,
ValidityIndicator,
VisuallyHidden,
} from '@wordpress/ui';
import { speak } from '@wordpress/a11y';
import {
MediaUpload,
uploadMedia,
privateApis as mediaUtilsPrivateApis,
} from '@wordpress/media-utils';
import { store as noticesStore } from '@wordpress/notices';
import { unlock } from '../../lock-unlock';
import type { MediaEditProps } from '../../types';
import useMovingAnimation from './use-moving-animation';
const { MediaUploadModal } = unlock( mediaUtilsPrivateApis );
function AnimatedMediaItem( {
children,
index,
className,
}: {
children: React.ReactNode;
index: number;
className?: string;
} ) {
const ref = useMovingAnimation( index );
return (
{ children }
);
}
type BlobItem = {
id: string;
source_url: string;
mime_type: string | undefined;
alt_text?: string;
};
function normalizeValue( value: number | number[] | undefined ): number[] {
if ( Array.isArray( value ) ) {
return value;
}
return value ? [ value ] : [];
}
/**
* Conditional Media component that uses MediaUploadModal when experiment is enabled,
* otherwise falls back to media-utils MediaUpload. Also what `FilteredMediaUpload`
* falls back to when nothing is registered on `editor.MediaUpload`, as in tests.
*
* @param root0 Component props.
* @param root0.render Render prop function that receives { open } object.
* @param root0.multiple Whether to allow multiple media selections; `'add'` keeps the current selection in the legacy media frame.
* @return The component.
*/
function ConditionalMediaUpload( { render, multiple, ...props }: any ) {
const [ isModalOpen, setIsModalOpen ] = useState( false );
if ( ( window as any ).__experimentalDataViewsMediaModal ) {
return (
<>
{ render && render( { open: () => setIsModalOpen( true ) } ) }
{ isModalOpen && (
{
setIsModalOpen( false );
props.onClose?.();
} }
onSelect={ ( media: any ) => {
setIsModalOpen( false );
props.onSelect?.( media );
} }
/>
) }
>
);
}
// Fallback to media-utils MediaUpload when experiment is disabled.
return ;
}
/*
* `ConditionalMediaUpload` resolved through `editor.MediaUpload`: the hook
* through which the editor supplies its media library to blocks and plugins
* extend it (extra sources, validation). Those callbacks assume an editor
* context, so `MediaEdit` does not use it; `MediaEditControl` does when
* `isPickerFiltered` is set, for fields that render inside the editor.
*/
const FilteredMediaUpload = withFilters( 'editor.MediaUpload' )(
ConditionalMediaUpload
) as React.ComponentType< Record< string, unknown > >;
function MediaPickerButton( {
open,
children,
label,
showTooltip = false,
onFilesDrop,
attachment,
isUploading = false,
}: {
open: () => void;
children: React.ReactNode;
label: string;
showTooltip?: boolean;
onFilesDrop: MediaEditAttachmentsProps[ 'onFilesDrop' ];
attachment?: MediaEditAttachment;
isUploading?: boolean;
} ) {
const isBlob = attachment && isBlobURL( attachment.source_url );
const mediaPickerButton = (
{
if ( ! isUploading ) {
open();
}
} }
onKeyDown={ ( event ) => {
if ( isUploading ) {
return;
}
if ( event.key === 'Enter' || event.key === ' ' ) {
event.preventDefault();
open();
}
} }
aria-label={ label }
aria-disabled={ isUploading }
>
{ children }
{ isBlob && (
) }
{ ! isUploading && (
onFilesDrop( files, attachment?.id as number )
}
/>
) }
);
if ( ! showTooltip ) {
return mediaPickerButton;
}
return (
{ label }
);
}
const archiveMimeTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/x-gzip',
];
function MediaTitle( { attachment }: { attachment: Attachment< 'view' > } ) {
return (
{ decodeEntities( attachment.title.rendered ) }
);
}
function MediaEditPlaceholder( props: {
open: () => void;
label: string;
onFilesDrop: MediaEditAttachmentsProps[ 'onFilesDrop' ];
isUploading: boolean;
} ) {
return (
{ props.label }
);
}
function MoveButtons( {
itemId,
index,
totalItems,
isUploading,
moveItem,
orientation = 'vertical',
}: {
itemId: number;
index: number;
totalItems: number;
isUploading: boolean;
moveItem: ( id: number, direction: 'up' | 'down' ) => void;
orientation?: 'vertical' | 'horizontal';
} ) {
const isHorizontal = orientation === 'horizontal';
return (
<>
) => {
event.stopPropagation();
moveItem( itemId, 'up' );
} }
/>
) => {
event.stopPropagation();
moveItem( itemId, 'down' );
} }
/>
>
);
}
function MediaPreview( { attachment }: { attachment: MediaEditAttachment } ) {
const url = attachment.source_url;
const mimeType = attachment.mime_type || '';
if ( mimeType.startsWith( 'image' ) ) {
return (
);
} else if ( mimeType.startsWith( 'audio' ) ) {
return ;
} else if ( mimeType.startsWith( 'video' ) ) {
return ;
} else if ( archiveMimeTypes.includes( mimeType ) ) {
return ;
}
return ;
}
type MediaEditAttachment = Attachment< 'view' > | BlobItem;
interface MediaEditAttachmentsProps {
allItems: Array< MediaEditAttachment > | null;
addButtonLabel: string;
multiple?: boolean;
removeItem: ( itemId: number ) => void;
moveItem: ( itemId: number, direction: 'up' | 'down' ) => void;
open: () => void;
onFilesDrop: ( files: File[], attachmentId?: number ) => void;
isUploading: boolean;
setTargetItemId: ( id?: number ) => void;
}
function ExpandedMediaEditAttachments( {
allItems,
addButtonLabel,
multiple,
removeItem,
moveItem,
open,
onFilesDrop,
isUploading,
setTargetItemId,
}: MediaEditAttachmentsProps ) {
return (
{ allItems?.map( ( attachment, index ) => {
const hasPreviewImage =
attachment.mime_type?.startsWith( 'image' );
const isBlob = isBlobURL( attachment.source_url );
const attachmentNumericId = attachment.id as number;
return (
{
setTargetItemId( attachmentNumericId );
open();
} }
label={
! isBlob
? sprintf(
/* translators: %s: The title of the media item. */
__( 'Replace %s' ),
decodeEntities(
(
attachment as Attachment< 'view' >
).title.rendered
)
)
: __( 'Replace' )
}
showTooltip
onFilesDrop={ onFilesDrop }
attachment={ attachment }
isUploading={ isUploading }
>
{ ( ! isBlob || hasPreviewImage ) && (
) }
{ ! isBlob && (
{ multiple && allItems.length > 1 && (
) }
) => {
event.stopPropagation();
removeItem( attachmentNumericId );
} }
/>
) }
);
} ) }
{ ( multiple || ! allItems?.length ) && (
{
setTargetItemId( undefined );
open();
} }
label={ addButtonLabel }
onFilesDrop={ onFilesDrop }
isUploading={ isUploading }
/>
) }
);
}
function CompactMediaEditAttachments( {
allItems,
addButtonLabel,
multiple,
removeItem,
moveItem,
open,
onFilesDrop,
isUploading,
setTargetItemId,
}: MediaEditAttachmentsProps ) {
return (
<>
{ !! allItems?.length && (
{ allItems.map( ( attachment, index ) => {
const isBlob = isBlobURL( attachment.source_url );
const showMoveButtons =
multiple && allItems.length > 1;
const attachmentNumericId = attachment.id as number;
return (
{
setTargetItemId(
attachmentNumericId
);
open();
} }
label={ __( 'Replace' ) }
showTooltip
onFilesDrop={ onFilesDrop }
attachment={ attachment }
isUploading={ isUploading }
>
<>
{ ! isBlob && (
}
/>
) }
>
{ ! isBlob && (
{ showMoveButtons && (
) }
) => {
event.stopPropagation();
removeItem(
attachmentNumericId
);
} }
/>
) }
);
} ) }
) }
{ ( multiple || ! allItems?.length ) && (
{
setTargetItemId( undefined );
open();
} }
label={ addButtonLabel }
onFilesDrop={ onFilesDrop }
isUploading={ isUploading }
/>
) }
>
);
}
/**
* A media edit control component that provides a media picker UI with upload functionality
* for selecting WordPress media attachments. Supports both the traditional WordPress media
* library and the experimental DataViews media modal.
*
* This component is intended to be used as the `Edit` property of a field definition when
* registering fields with `registerEntityField` from `@wordpress/editor`.
*
* @template Item - The type of the item being edited.
*
* @param {MediaEditProps- } props - The component props.
* @param {Item} props.data - The item being edited.
* @param {Object} props.field - The field configuration with getValue and setValue methods.
* @param {Function} props.onChange - Callback function when the media selection changes.
* @param {string[]} [props.allowedTypes] - Array of allowed media types. Use `['*']` to allow all file types. Default `['image']`.
* @param {boolean} [props.multiple] - Whether to allow multiple media selections. Default `false`.
* @param {boolean} [props.hideLabelFromVision] - Whether the label should be hidden from vision.
* @param {boolean} [props.isExpanded] - Whether to render in an expanded form. Default `false`.
*
* @return {React.JSX.Element} The media edit control component.
*
* @example
* ```tsx
* import { MediaEdit } from '@wordpress/fields';
* import type { DataFormControlProps } from '@wordpress/dataviews';
*
* const featuredImageField = {
* id: 'featured_media',
* type: 'media',
* label: 'Featured Image',
* Edit: (props: DataFormControlProps
) => (
*
* ),
* };
* ```
*/
export default function MediaEdit< Item >( props: MediaEditProps< Item > ) {
return ;
}
/*
* Body of `MediaEdit`. Package-internal: the featured image field renders it
* for the props the public control doesn't offer.
*/
export function MediaEditControl< Item >( {
data,
field,
onChange,
hideLabelFromVision,
allowedTypes = [ 'image' ],
multiple,
isExpanded,
validity,
isPickerFiltered,
featuredImageFlow,
pickerTitle,
}: MediaEditProps< Item > & {
/**
* Whether the picker resolves through the `editor.MediaUpload` filter, so
* the plugin extensions registered on it apply.
*/
isPickerFiltered?: boolean;
/**
* Opens the featured-image media frame, as the classic panel does; plugins
* extending `editor.MediaUpload` recognize the featured image by it.
*/
featuredImageFlow?: boolean;
/**
* Title of the picker, in place of the field label.
*/
pickerTitle?: string;
} ) {
const MediaUploadComponent = isPickerFiltered
? FilteredMediaUpload
: ConditionalMediaUpload;
const value = field.getValue( { item: data } );
// While the permission is unresolved, show the picker, as the editor does.
const canUpload = useSelect(
( select ) =>
select( coreStore ).canUser( 'create', {
kind: 'postType',
name: 'attachment',
} ) ?? true,
[]
);
const [ isTouched, setIsTouched ] = useState( false );
const validityTargetRef = useRef< HTMLInputElement >( null );
const [ customValidity, setCustomValidity ] = useState<
| { type: 'valid' | 'validating' | 'invalid'; message?: string }
| undefined
>( undefined );
// Listen for invalid event (e.g., form submission, reportValidity())
// to show validation messages even before blur.
useEffect( () => {
const validityTarget = validityTargetRef.current;
const handler = () => {
setIsTouched( true );
};
validityTarget?.addEventListener( 'invalid', handler );
return () => validityTarget?.removeEventListener( 'invalid', handler );
}, [] );
const attachments = useSelect(
( select ) => {
if ( ! value ) {
return null;
}
const normalizedValue = normalizeValue( value );
// Sorted IDs ensure stable cache key, avoiding
// unnecessary new requests on reorder.
const sortedIds = normalizedValue.toSorted( ( a, b ) => a - b );
const { getEntityRecords } = select( coreStore );
return getEntityRecords( 'postType', 'attachment', {
include: sortedIds,
} ) as Attachment< 'view' >[] | null;
},
[ value ]
);
// Keep previous attachments during null transitions. When value changes,
// useSelect briefly returns null while the new query resolves. For pure
// reorders (same IDs), we fall back to the cached list to avoid a visual
// flash in compact mode. For replacements/uploads (new IDs not in cache),
// we let attachments be null as normal.
const stableAttachmentsRef = useRef< Attachment< 'view' >[] >( null );
if ( attachments !== null ) {
stableAttachmentsRef.current = attachments;
}
let stableAttachments = attachments;
if ( attachments === null && stableAttachmentsRef.current && value ) {
const stableIds = new Set(
stableAttachmentsRef.current.map( ( a ) => a.id )
);
if ( normalizeValue( value ).every( ( id ) => stableIds.has( id ) ) ) {
stableAttachments = stableAttachmentsRef.current;
}
}
// Reorder attachments to match value order.
const orderedAttachments = useMemo( () => {
if ( ! stableAttachments ) {
return null;
}
const normalizedValue = normalizeValue( value );
const attachmentMap = new Map(
stableAttachments.map( ( a ) => [ a.id, a ] )
);
return normalizedValue
.map( ( id ) => attachmentMap.get( id ) )
.filter( ( a ): a is Attachment< 'view' > => a !== undefined );
}, [ stableAttachments, value ] );
const { createErrorNotice } = useDispatch( noticesStore );
const { receiveEntityRecords } = useDispatch( coreStore );
// Support one upload action at a time for now.
const [ targetItemId, setTargetItemId ] = useState< number >();
// Deferred open: the legacy class-based MediaUpload reads props
// imperatively when `open()` is called, so calling it in the same
// handler as `setTargetItemId()` would open the modal with stale
// `value`/`multiple` props. Setting a pending flag defers the open
// until after the next render when props are up to date.
const openModalRef = useRef< () => void >( undefined );
const [ pendingOpen, setPendingOpen ] = useState( false );
const [ blobs, setBlobs ] = useState< string[] >( [] );
useEffect( () => {
if ( pendingOpen ) {
setPendingOpen( false );
openModalRef.current?.();
}
}, [ pendingOpen ] );
const onChangeControl = useCallback(
( newValue: number | number[] | undefined ) =>
onChange( field.setValue( { item: data, value: newValue } ) ),
[ data, field, onChange ]
);
const removeItem = useCallback(
( itemId: number ) => {
const currentIds = normalizeValue( value );
const newIds = currentIds.filter( ( id ) => id !== itemId );
// Mark as touched to immediately show any validation error.
setIsTouched( true );
onChangeControl( newIds.length ? newIds : undefined );
},
[ value, onChangeControl ]
);
const moveItem = useCallback(
( itemId: number, direction: 'up' | 'down' ) => {
if ( ! orderedAttachments ) {
return;
}
const currentIds = orderedAttachments.map( ( a ) => a.id );
const index = currentIds.indexOf( itemId );
const newIndex = direction === 'up' ? index - 1 : index + 1;
[ currentIds[ index ], currentIds[ newIndex ] ] = [
currentIds[ newIndex ],
currentIds[ index ],
];
onChangeControl( currentIds );
},
[ orderedAttachments, onChangeControl ]
);
const onFilesDrop = useCallback(
( files: File[], _targetItemId?: number ) => {
setTargetItemId( _targetItemId );
uploadMedia( {
allowedTypes: allowedTypes?.length ? allowedTypes : undefined,
filesList: files,
onFileChange( uploadedMedia: any[] ) {
const blobUrls = uploadedMedia
.filter( ( item ) => isBlobURL( item.url ) )
.map( ( item ) => item.url );
setBlobs( blobUrls );
// Wait for all uploads to complete before updating value.
if ( !! blobUrls.length ) {
return;
}
// `uploadMedia` creates attachments via `apiFetch`
// outside the core-data store, so invalidate
// all attachment queries to keep them fresh for
// other components that rely on core-data.
receiveEntityRecords(
'postType',
'attachment',
[],
undefined,
true
);
const uploadedIds = uploadedMedia.map(
( item ) => item.id
);
if ( ! multiple ) {
onChangeControl( uploadedIds[ 0 ] );
setTargetItemId( undefined );
return;
}
const currentValue = normalizeValue( value );
// Dropped on placeholder: append new items.
if ( _targetItemId === undefined ) {
onChangeControl( [ ...currentValue, ...uploadedIds ] );
} else {
// Dropped on existing item: insert at that position.
const newValue = [ ...currentValue ];
newValue.splice(
currentValue.indexOf( _targetItemId ),
1,
...uploadedIds
);
onChangeControl( newValue );
}
setTargetItemId( undefined );
},
onError( error: Error ) {
setTargetItemId( undefined );
setBlobs( [] );
createErrorNotice( error.message, { type: 'snackbar' } );
},
multiple: !! multiple,
} );
},
[
allowedTypes,
value,
multiple,
createErrorNotice,
onChangeControl,
receiveEntityRecords,
]
);
const addButtonLabel =
field.placeholder ||
( multiple ? __( 'Choose files' ) : __( 'Choose file' ) );
// Merge real attachments with any existing blob items that are being uploaded.
const allItems: Array< MediaEditAttachment > | null = useMemo( () => {
if ( ! blobs.length ) {
return orderedAttachments;
}
const items: Array< MediaEditAttachment > = [
...( orderedAttachments || [] ),
];
const blobItems = blobs.map( ( url ) => ( {
id: url,
source_url: url,
mime_type: getBlobTypeByURL( url ),
} ) );
if ( targetItemId !== undefined ) {
// When files are dropped in existing media item, place the blobs at that item.
const targetIndex = items.findIndex(
( a ) => a.id === targetItemId
);
items.splice( targetIndex, 1, ...blobItems );
} else {
items.push( ...blobItems );
}
return items;
}, [ orderedAttachments, targetItemId, blobs ] );
useEffect( () => {
if ( ! isTouched ) {
return;
}
const input = validityTargetRef.current;
if ( ! input ) {
return;
}
if ( validity ) {
const customValidityResult = validity?.custom;
setCustomValidity( customValidityResult );
// Set custom validity on hidden input for HTML5 form validation.
if ( customValidityResult?.type === 'invalid' ) {
input.setCustomValidity(
customValidityResult.message || __( 'Invalid' )
);
} else {
input.setCustomValidity( '' ); // Clear validity.
}
} else {
// Clear any previous validation.
input.setCustomValidity( '' );
setCustomValidity( undefined );
}
}, [ isTouched, field.isValid, validity ] );
useEffect( () => {
if ( isTouched && customValidity?.message ) {
speak( customValidity.message );
}
}, [ isTouched, customValidity?.message ] );
const onBlur = useCallback(
( event: React.FocusEvent< HTMLElement > ) => {
if ( isTouched ) {
return;
}
if (
! event.relatedTarget ||
! event.currentTarget.contains( event.relatedTarget )
) {
setIsTouched( true );
}
},
[ isTouched ]
);
if ( ! canUpload ) {
return (
{ sprintf(
/* translators: %s: The field label. */
__(
'%s: To edit this field, you need permission to upload media.'
),
field.label
) }
);
}
return (
{
if ( ! multiple ) {
onChangeControl( selectedMedia.id );
setTargetItemId( undefined );
return;
}
const newIds = Array.isArray( selectedMedia )
? selectedMedia.map( ( m: any ) => m.id )
: [ selectedMedia.id ];
const currentValue = normalizeValue( value );
if ( ! currentValue.length ) {
onChangeControl( newIds );
} else if ( targetItemId === undefined ) {
// Placeholder clicked: keep existing items that are
// still selected, then append newly selected items.
const existingItems = currentValue.filter( ( id ) =>
newIds.includes( id )
);
const newItems = newIds.filter(
( id ) => ! currentValue.includes( id )
);
onChangeControl( [
...existingItems,
...newItems,
] );
} else if ( selectedMedia.id !== targetItemId ) {
// Remove selected item from its old position, if it
// already exists in the value.
const filtered = currentValue.filter(
( id ) => id !== selectedMedia.id
);
// Replace the clicked item with the selected one.
onChangeControl(
filtered.map( ( id ) =>
id === targetItemId ? selectedMedia.id : id
)
);
}
setTargetItemId( undefined );
} }
onClose={ () => setTargetItemId( undefined ) }
allowedTypes={ allowedTypes }
// When replacing an existing item, pass only that item's ID
// and open in single-select mode so the user picks exactly
// one replacement, even if `multiple` is true.
value={ targetItemId !== undefined ? targetItemId : value }
// Pickers on `editor.MediaUpload` take the block-editor
// `MediaUpload` value, `'add'` to keep the current selection
// or `false`, so the control speaks that API.
multiple={
multiple && targetItemId === undefined ? 'add' : false
}
render={ ( { open }: any ) => {
// Keep a ref to the latest `open` so the deferred effect can call it.
openModalRef.current = open;
const AttachmentsComponent = isExpanded
? ExpandedMediaEditAttachments
: CompactMediaEditAttachments;
return (
{ field.label &&
( hideLabelFromVision ? (
}>
{ field.label }
) : (
{ field.label }
) ) }
setPendingOpen( true ) }
onFilesDrop={ onFilesDrop }
isUploading={ !! blobs.length }
setTargetItemId={ setTargetItemId }
/>
{ field.description && (
{ field.description }
) }
);
} }
/>
{ /* Visually hidden text input for validation. */ }
{} }
/>
{ customValidity && (
) }
);
}