@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
438 lines (414 loc) • 15.3 kB
JavaScript
/**
* The `editor` settings here need to be in sync with the corresponding ones in `editor` package.
* See `packages/editor/src/components/media-categories/index.js`.
*
* In the future we could consider creating an Openvese package that can be used in both `editor` and `site-editor`.
* The rest of the settings would still need to be in sync though.
*/
import { __, sprintf, _x } from '@wordpress/i18n';
import { dispatch, resolveSelect, select, subscribe } from '@wordpress/data';
import { decodeEntities } from '@wordpress/html-entities';
import { store as coreStore } from '@wordpress/core-data';
/** @typedef {import('@wordpress/block-editor').InserterMediaRequest} InserterMediaRequest */
/** @typedef {import('@wordpress/block-editor').InserterMediaItem} InserterMediaItem */
/** @typedef {import('@wordpress/block-editor').InserterMediaCategory} InserterMediaCategory */
const getExternalLink = ( url, text ) =>
`<a ${ getExternalLinkAttributes( url ) }>${ text }</a>`;
const getExternalLinkAttributes = ( url ) =>
`href="${ url }" target="_blank" rel="noopener"`;
const getOpenverseLicense = ( license, licenseVersion ) => {
let licenseName = license.trim();
// PDM has no abbreviation
if ( license !== 'pdm' ) {
licenseName = license.toUpperCase().replace( 'SAMPLING', 'Sampling' );
}
// If version is known, append version to the name.
// The license has to have a version to be valid. Only
// PDM (public domain mark) doesn't have a version.
if ( licenseVersion ) {
licenseName += ` ${ licenseVersion }`;
}
// For licenses other than public-domain marks, prepend 'CC' to the name.
if ( ! [ 'pdm', 'cc0' ].includes( license ) ) {
licenseName = `CC ${ licenseName }`;
}
return licenseName;
};
const getOpenverseCaption = ( item ) => {
const {
title,
foreign_landing_url: foreignLandingUrl,
creator,
creator_url: creatorUrl,
license,
license_version: licenseVersion,
license_url: licenseUrl,
} = item;
const fullLicense = getOpenverseLicense( license, licenseVersion );
const _creator = decodeEntities( creator );
let _caption;
if ( _creator ) {
_caption = title
? sprintf(
// translators: %1s: Title of a media work from Openverse; %2$s: Name of the work's creator; %3s: Work's licence e.g: "CC0 1.0".
_x( '"%1$s" by %2$s/ %3$s', 'caption' ),
getExternalLink(
foreignLandingUrl,
decodeEntities( title )
),
creatorUrl
? getExternalLink( creatorUrl, _creator )
: _creator,
licenseUrl
? getExternalLink(
`${ licenseUrl }?ref=openverse`,
fullLicense
)
: fullLicense
)
: sprintf(
// translators: %1s: Link attributes for a given Openverse media work; %2s: Name of the work's creator; %3s: Works's licence e.g: "CC0 1.0".
_x( '<a %1$s>Work</a> by %2$s/ %3$s', 'caption' ),
getExternalLinkAttributes( foreignLandingUrl ),
creatorUrl
? getExternalLink( creatorUrl, _creator )
: _creator,
licenseUrl
? getExternalLink(
`${ licenseUrl }?ref=openverse`,
fullLicense
)
: fullLicense
);
} else {
_caption = title
? sprintf(
// translators: %1s: Title of a media work from Openverse; %2s: Work's licence e.g: "CC0 1.0".
_x( '"%1$s"/ %2$s', 'caption' ),
getExternalLink(
foreignLandingUrl,
decodeEntities( title )
),
licenseUrl
? getExternalLink(
`${ licenseUrl }?ref=openverse`,
fullLicense
)
: fullLicense
)
: sprintf(
// translators: %1s: Link attributes for a given Openverse media work; %2s: Works's licence e.g: "CC0 1.0".
_x( '<a %1$s>Work</a>/ %2$s', 'caption' ),
getExternalLinkAttributes( foreignLandingUrl ),
licenseUrl
? getExternalLink(
`${ licenseUrl }?ref=openverse`,
fullLicense
)
: fullLicense
);
}
return _caption.replace( /\s{2}/g, ' ' );
};
const getCoreMediaQuery = ( query = {} ) => ( {
...query,
orderBy: !! query?.search ? 'relevance' : 'date',
} );
const coreMediaFetch = async ( query = {} ) => {
// Use the same final query for the records fetch and the totals selectors so
// their cached query key matches and the totals resolve to this exact request.
const finalQuery = getCoreMediaQuery( query );
const records = await resolveSelect( coreStore ).getEntityRecords(
'postType',
'attachment',
finalQuery
);
// Totals are read synchronously after resolution — the `getEntityRecords`
// resolver captures them from the `X-WP-Total` / `X-WP-TotalPages` response
// headers, and `resolveSelect().getEntityRecords()` only returns the records.
const totalItems = select( coreStore ).getEntityRecordsTotalItems(
'postType',
'attachment',
finalQuery
);
const totalPages = select( coreStore ).getEntityRecordsTotalPages(
'postType',
'attachment',
finalQuery
);
return {
mediaItems: records.map( ( record ) => ( {
...record,
alt: record.alt_text,
url: record.source_url,
previewUrl: record.media_details?.sizes?.medium?.source_url,
caption: record.caption?.raw,
} ) ),
totalItems,
totalPages,
};
};
const getAttachedImagesQuery = ( postId, query = {} ) => ( {
...query,
media_type: 'image',
parent: postId,
} );
const normalizePostId = ( postId ) => {
const parsedPostId = typeof postId === 'number' ? postId : Number( postId );
return Number.isInteger( parsedPostId ) && parsedPostId > 0
? parsedPostId
: undefined;
};
const saveAttachmentParent = ( attachmentId, postId ) =>
// `throwOnError` so a failed REST write rejects (rather than being silently
// swallowed), letting the attach/detach handlers surface an error notice
// instead of a false success.
dispatch( coreStore ).saveEntityRecord(
'postType',
'attachment',
{
id: attachmentId,
post: postId,
},
{ throwOnError: true }
);
// A selected media item's coarse type is exposed differently by each picker.
// The classic media modal puts the media type directly on `type` (e.g. 'image').
// The DataViews-driven modal passes REST attachment records, where `type` is the
// *post* type ('attachment') and the media type lives in `media_type`
// ('image'|'file') / `mime_type`. So the REST fields must be read first, with
// `type` as the classic-modal fallback — otherwise a REST image reads as
// 'attachment' and gets gated out.
const getMediaItemType = ( mediaItem ) =>
mediaItem?.media_type ||
mediaItem?.mime_type?.split( '/' )[ 0 ] ||
mediaItem?.type;
// The picker's "Upload files" tab accepts any file type, so the selection can
// include non-images. Gate to images only: a non-image would be reparented to
// the post but never appear in the image-filtered grid, and would wrongly count
// toward the "images attached" notice.
const getImageAttachmentIds = ( mediaItems ) => [
...new Set(
( Array.isArray( mediaItems ) ? mediaItems : [ mediaItems ] )
.filter(
( mediaItem ) => getMediaItemType( mediaItem ) === 'image'
)
.map( ( mediaItem ) => mediaItem?.id )
.filter( Boolean )
),
];
const invalidateAttachedImagesQueries = ( postId, query = {} ) => {
const { invalidateResolution } = dispatch( coreStore );
// Invalidate the resolution backing the visible grid so it refetches after
// an attach/detach and reflects the updated set of attached images. The tab
// is always shown (via `emptyMessage`), so there's no separate visibility
// probe to invalidate.
invalidateResolution( 'getEntityRecords', [
'postType',
'attachment',
getCoreMediaQuery( getAttachedImagesQuery( postId, query ) ),
] );
};
// The inserter panel fetches imperatively into local state, so it can't react to
// attachment cache invalidation on its own. Calls `onChange` on the resolved ->
// unresolved edge of the resolution the grid reads, i.e. when that cache is
// invalidated. `args` must match what `coreMediaFetch` resolves byte-for-byte,
// since `invalidateResolution` keys on deep argument equality.
const subscribeToMediaInvalidation = ( args, onChange ) => {
const isResolved = () =>
select( coreStore ).hasFinishedResolution( 'getEntityRecords', args );
let wasResolved = isResolved();
// Scoped to `coreStore` so the listener only runs on core-data changes.
return subscribe( () => {
const nowResolved = isResolved();
if ( wasResolved && ! nowResolved ) {
onChange();
}
wasResolved = nowResolved;
}, coreStore );
};
// Builds a core-data-backed category from a single `getQuery` mapper, so `fetch`
// and `subscribe` can't drift apart on the resolution args. `coreMediaFetch`
// applies `getCoreMediaQuery` internally, so `subscribe` mirrors it. External
// sources (e.g. Openverse) don't use this and simply omit `subscribe`.
const createCoreMediaCategory = ( { getQuery, ...category } ) => ( {
...category,
async fetch( query = {} ) {
return coreMediaFetch( getQuery( query ) );
},
subscribe( onChange, query = {} ) {
return subscribeToMediaInvalidation(
[
'postType',
'attachment',
getCoreMediaQuery( getQuery( query ) ),
],
onChange
);
},
} );
/**
* Builds the "Attachments" media category for a given post. It behaves like
* any other inserter media source (e.g. Openverse): it appears in the tab list
* and renders through the shared media panel. In addition to `fetch`, it exposes
* optional `attach`/`detach`/`invalidate` capabilities that the shared panel
* picks up to offer an "Attach images" button and a per-item "Detach from post"
* action in the same dropdown Openverse uses for "Report image". It also exposes
* `subscribe`, so the panel can refetch when the attachment cache is invalidated
* elsewhere (e.g. a media modal closing after an upload).
*
* @param {number} postId The current post id.
* @param {string|null} [typeLabel] The post type's singular label to use in copy (e.g. "Page"),
* or null to fall back to the generic "post".
* @return {InserterMediaCategory} The Attachments media category.
*/
const getAttachedImagesCategory = ( postId, typeLabel ) =>
createCoreMediaCategory( {
name: 'attached-images',
labels: {
name: __( 'Attached images' ),
search_items: __( 'Search attachments' ),
},
mediaType: 'image',
getQuery: ( query ) => getAttachedImagesQuery( postId, query ),
// The post type's singular label (e.g. "Page"), threaded through so the
// shared panel can word its attach/detach copy for the current post type.
postTypeLabel: typeLabel,
// Empty-state message. Providing this also keeps the source in the tab
// list when it has no items, so it stays discoverable and the first
// image can be attached even with none yet.
emptyMessage: typeLabel
? sprintf(
// translators: %s: Name of the post type e.g: "Page".
__( 'No images attached to this %s.' ),
typeLabel
)
: __( 'No images attached to this post.' ),
async attach( mediaItems ) {
const attachmentIds = getImageAttachmentIds( mediaItems );
await Promise.all(
attachmentIds.map( ( attachmentId ) =>
saveAttachmentParent( attachmentId, postId )
)
);
return attachmentIds.length;
},
async detach( mediaItem ) {
await saveAttachmentParent( mediaItem.id, 0 );
},
invalidate( query = {} ) {
invalidateAttachedImagesQueries( postId, query );
},
} );
/** @type {InserterMediaCategory[]} */
const inserterMediaCategories = [
createCoreMediaCategory( {
name: 'images',
labels: {
name: __( 'Images' ),
search_items: __( 'Search images' ),
},
mediaType: 'image',
getQuery: ( query ) => ( { ...query, media_type: 'image' } ),
} ),
createCoreMediaCategory( {
name: 'videos',
labels: {
name: __( 'Videos' ),
search_items: __( 'Search videos' ),
},
mediaType: 'video',
getQuery: ( query ) => ( { ...query, media_type: 'video' } ),
} ),
createCoreMediaCategory( {
name: 'audio',
labels: {
name: __( 'Audio' ),
search_items: __( 'Search audio' ),
},
mediaType: 'audio',
getQuery: ( query ) => ( { ...query, media_type: 'audio' } ),
} ),
{
name: 'openverse',
labels: {
name: __( 'Openverse' ),
search_items: __( 'Search Openverse' ),
},
mediaType: 'image',
async fetch( query = {} ) {
const defaultArgs = {
mature: false,
excluded_source: 'flickr,inaturalist,wikimedia',
license: 'pdm,cc0',
};
const finalQuery = { ...query, ...defaultArgs };
const mapFromInserterMediaRequest = {
per_page: 'page_size',
search: 'q',
};
const url = new URL( 'https://api.openverse.org/v1/images/' );
Object.entries( finalQuery ).forEach( ( [ key, value ] ) => {
const queryKey = mapFromInserterMediaRequest[ key ] || key;
url.searchParams.set( queryKey, value );
} );
const response = await window.fetch( url, {
headers: {
'User-Agent': 'WordPress/inserter-media-fetch',
},
} );
const jsonResponse = await response.json();
const results = jsonResponse.results;
// This external source returns a plain array, so it renders without a
// pager (the shared panel treats a non-object result as a single
// page). To paginate it later, return the same
// `{ mediaItems, totalItems, totalPages }` shape the core sources use,
// mapping `jsonResponse.result_count` -> `totalItems` and
// `jsonResponse.page_count` -> `totalPages` (Openverse already accepts
// a `page` query arg, which passes straight through above).
return results.map( ( result ) => ( {
...result,
// This is a temp solution for better titles, until Openverse API
// completes the cleaning up of some titles of their upstream data.
title: result.title?.toLowerCase().startsWith( 'file:' )
? result.title.slice( 5 )
: result.title,
sourceId: result.id,
id: undefined,
caption: getOpenverseCaption( result ),
previewUrl: result.thumbnail,
} ) );
},
getReportUrl: ( { sourceId } ) =>
`https://wordpress.org/openverse/image/${ sourceId }/report/`,
isExternalResource: true,
},
];
/**
* Returns the inserter media categories for a given post. The "Attachments"
* category is prepended only when editing real, front-end-rendered content
* (posts, pages, public custom post types). It is omitted for synced patterns,
* navigation menus and templates, which aren't the entity that actually gets
* rendered, so attaching media to them is meaningless.
*
* @param {number|string} postId The current post id.
* @param {string} [viewablePostTypeLabel] Singular label of the post type, set only when it is front-end viewable (post, page, public CPT).
* @return {InserterMediaCategory[]} The inserter media categories.
*/
export default function getInserterMediaCategories(
postId,
viewablePostTypeLabel
) {
const currentPostId = normalizePostId( postId );
// A falsy label means either a non-viewable post type (synced pattern,
// navigation, template) or that the record hasn't resolved yet — in both
// cases the category is omitted. A numeric id is also required since it
// backs the attachment `parent` query.
if ( ! currentPostId || ! viewablePostTypeLabel ) {
return inserterMediaCategories;
}
return [
getAttachedImagesCategory( currentPostId, viewablePostTypeLabel ),
...inserterMediaCategories,
];
}