diff --git a/docs/manifest.json b/docs/manifest.json index 73803ab67cd01b..a47ca5c5d9dcfb 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1847,6 +1847,12 @@ "markdown_source": "../packages/list-reusable-blocks/README.md", "parent": "packages" }, + { + "title": "@wordpress/media-fields", + "slug": "packages-media-fields", + "markdown_source": "../packages/media-fields/README.md", + "parent": "packages" + }, { "title": "@wordpress/media-utils", "slug": "packages-media-utils", diff --git a/package-lock.json b/package-lock.json index 747e7357df14c1..c8fc5288844428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17905,6 +17905,10 @@ "resolved": "packages/list-reusable-blocks", "link": true }, + "node_modules/@wordpress/media-fields": { + "resolved": "packages/media-fields", + "link": true + }, "node_modules/@wordpress/media-utils": { "resolved": "packages/media-utils", "link": true @@ -54523,6 +54527,29 @@ "react-dom": "^18.0.0" } }, + "packages/media-fields": { + "name": "@wordpress/media-fields", + "version": "0.1.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/components": "file:../components", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/dataviews": "file:../dataviews", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/primitives": "file:../primitives", + "@wordpress/url": "file:../url" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "packages/media-utils": { "name": "@wordpress/media-utils", "version": "5.36.0", @@ -54537,6 +54564,7 @@ "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/media-fields": "file:../media-fields", "@wordpress/private-apis": "file:../private-apis" }, "engines": { diff --git a/packages/media-fields/README.md b/packages/media-fields/README.md new file mode 100644 index 00000000000000..e28ec17a999f0d --- /dev/null +++ b/packages/media-fields/README.md @@ -0,0 +1,52 @@ +# Media Fields + +This package provides reusable field definitions for displaying and editing media attachment properties in WordPress DataViews. It's primarily intended for internal use within Gutenberg and may change significantly between releases. + +## Usage + +### Available Fields + +This package exports field definitions for common media attachment properties: + +- `altTextField` - Alternative text for images +- `captionField` - Media caption text +- `descriptionField` - Detailed description +- `filenameField` - File name (read-only) +- `filesizeField` - File size with human-readable formatting +- `mediaDimensionsField` - Image dimensions (width × height) +- `mediaThumbnailField` - Thumbnail preview +- `mimeTypeField` - MIME type display + +### Using Media Fields in DataViews + +```jsx +import { + altTextField, + captionField, + filesizeField, +} from '@wordpress/media-fields'; +import { DataViews } from '@wordpress/dataviews'; + +const fields = [ + altTextField, + captionField, + filesizeField, +]; + +export function MyMediaLibrary( { items } ) { + return ( + + ); +} +``` + +## Contributing to this package + +This package is part of the Gutenberg project. To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/media-fields/package.json b/packages/media-fields/package.json new file mode 100644 index 00000000000000..78db528ccc1d18 --- /dev/null +++ b/packages/media-fields/package.json @@ -0,0 +1,60 @@ +{ + "name": "@wordpress/media-fields", + "version": "0.1.0", + "description": "Reusable field definitions for displaying and editing media attachment properties in WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "media", + "fields", + "dataviews" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/media-fields/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/media-fields" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "exports": { + ".": { + "types": "./build-types/index.d.ts", + "import": "./build-module/index.js", + "require": "./build/index.js" + }, + "./package.json": "./package.json" + }, + "react-native": "src/index", + "types": "build-types", + "sideEffects": [ + "build-style/**", + "src/**/*.scss" + ], + "dependencies": { + "@wordpress/components": "file:../components", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/dataviews": "file:../dataviews", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/primitives": "file:../primitives", + "@wordpress/url": "file:../url" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/media-fields/src/alt_text/index.tsx b/packages/media-fields/src/alt_text/index.tsx new file mode 100644 index 00000000000000..6b399c797a8a5d --- /dev/null +++ b/packages/media-fields/src/alt_text/index.tsx @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { TextareaControl } from '@wordpress/components'; +import type { Field } from '@wordpress/dataviews'; +import type { Attachment, Updatable } from '@wordpress/core-data'; + +const altTextField: Partial< Field< Updatable< Attachment > > > = { + id: 'alt_text', + type: 'text', + label: __( 'Alt text' ), + isVisible: ( item ) => item?.media_type === 'image', + render: ( { item } ) => item?.alt_text || '-', + Edit: ( { field, onChange, data } ) => { + return ( + onChange( { alt_text: value } ) } + rows={ 2 } + __nextHasNoMarginBottom + /> + ); + }, + enableSorting: false, + filterBy: false, +}; + +export default altTextField; diff --git a/packages/media-fields/src/caption/index.tsx b/packages/media-fields/src/caption/index.tsx new file mode 100644 index 00000000000000..00e93449c8a937 --- /dev/null +++ b/packages/media-fields/src/caption/index.tsx @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { TextareaControl } from '@wordpress/components'; +import type { Attachment, Updatable } from '@wordpress/core-data'; +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { getRawContent } from '../utils/get-raw-content'; + +const captionField: Partial< Field< Updatable< Attachment > > > = { + id: 'caption', + type: 'text', + label: __( 'Caption' ), + getValue: ( { item } ) => getRawContent( item?.caption ), + render: ( { item } ) => getRawContent( item?.caption ) || '-', + Edit: ( { field, onChange, data } ) => { + return ( + onChange( { caption: value } ) } + rows={ 2 } + __nextHasNoMarginBottom + /> + ); + }, + enableSorting: false, + filterBy: false, +}; + +export default captionField; diff --git a/packages/media-fields/src/description/index.tsx b/packages/media-fields/src/description/index.tsx new file mode 100644 index 00000000000000..3516ce027ce737 --- /dev/null +++ b/packages/media-fields/src/description/index.tsx @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { TextareaControl } from '@wordpress/components'; +import type { Attachment, Updatable } from '@wordpress/core-data'; +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { getRawContent } from '../utils/get-raw-content'; + +const descriptionField: Partial< Field< Updatable< Attachment > > > = { + id: 'description', + type: 'text', + label: __( 'Description' ), + getValue: ( { item } ) => getRawContent( item?.description ), + render: ( { item } ) => ( +
{ getRawContent( item?.description ) || '-' }
+ ), + Edit: ( { field, onChange, data } ) => { + return ( + onChange( { description: value } ) } + rows={ 5 } + __nextHasNoMarginBottom + /> + ); + }, + enableSorting: false, + filterBy: false, +}; + +export default descriptionField; diff --git a/packages/media-fields/src/filename/index.ts b/packages/media-fields/src/filename/index.ts new file mode 100644 index 00000000000000..1e67b4920d0416 --- /dev/null +++ b/packages/media-fields/src/filename/index.ts @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { getFilename } from '@wordpress/url'; +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import type { MediaItem } from '../types'; +import FileNameView from './view'; + +const filenameField: Partial< Field< MediaItem > > = { + id: 'filename', + type: 'text', + label: __( 'File name' ), + getValue: ( { item }: { item: MediaItem } ) => + getFilename( item?.source_url || '' ), + render: FileNameView, + enableSorting: false, + filterBy: false, + readOnly: true, +}; + +export default filenameField; diff --git a/packages/media-fields/src/filename/view.tsx b/packages/media-fields/src/filename/view.tsx new file mode 100644 index 00000000000000..d7a45fa81214fd --- /dev/null +++ b/packages/media-fields/src/filename/view.tsx @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { + Tooltip, + __experimentalTruncate as Truncate, +} from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; +import { getFilename } from '@wordpress/url'; +import type { DataViewRenderFieldProps } from '@wordpress/dataviews'; +/** + * Internal dependencies + */ +import type { MediaItem } from '../types'; + +// Hard-coded truncate length to match the available area in the media sidebar. +// Longer file names will be truncated and wrapped in a tooltip showing the full name. +const TRUNCATE_LENGTH = 15; + +export default function FileNameView( { + item, +}: DataViewRenderFieldProps< MediaItem > ) { + const fileName = useMemo( + () => ( item?.source_url ? getFilename( item.source_url ) : null ), + [ item?.source_url ] + ); + + if ( ! fileName ) { + return ''; + } + + return fileName.length > TRUNCATE_LENGTH ? ( + + { fileName } + + ) : ( + <>{ fileName } + ); +} diff --git a/packages/media-fields/src/filesize/index.tsx b/packages/media-fields/src/filesize/index.tsx new file mode 100644 index 00000000000000..0bf7e83188a64f --- /dev/null +++ b/packages/media-fields/src/filesize/index.tsx @@ -0,0 +1,96 @@ +/** + * WordPress dependencies + */ +import { __, sprintf, _x } from '@wordpress/i18n'; +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import type { MediaItem } from '../types'; + +const KB_IN_BYTES = 1024; +const MB_IN_BYTES = 1024 * KB_IN_BYTES; +const GB_IN_BYTES = 1024 * MB_IN_BYTES; +const TB_IN_BYTES = 1024 * GB_IN_BYTES; +const PB_IN_BYTES = 1024 * TB_IN_BYTES; +const EB_IN_BYTES = 1024 * PB_IN_BYTES; +const ZB_IN_BYTES = 1024 * EB_IN_BYTES; +const YB_IN_BYTES = 1024 * ZB_IN_BYTES; + +function getBytesString( + bytes: number, + unitSymbol: string, + decimals = 2 +): string { + return sprintf( + // translators: 1: Actual bytes of a file. 2: The unit symbol (e.g. MB). + _x( '%1$s %2$s', 'file size' ), + bytes.toLocaleString( undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + } ), + unitSymbol + ); +} + +/** + * Converts bytes to a human-readable file size string with a specified number of decimal places. + * + * This logic is comparable to core's `size_format()` function. + * + * @param bytes The size in bytes. + * @param decimals The number of decimal places to include in the result. + * @return The human-readable file size string. + */ +function formatFileSize( bytes: number, decimals = 2 ): string { + if ( bytes === 0 ) { + return getBytesString( 0, _x( 'B', 'unit symbol' ), decimals ); + } + const quant = { + /* translators: Unit symbol for yottabyte. */ + [ _x( 'YB', 'unit symbol' ) ]: YB_IN_BYTES, + /* translators: Unit symbol for zettabyte. */ + [ _x( 'ZB', 'unit symbol' ) ]: ZB_IN_BYTES, + /* translators: Unit symbol for exabyte. */ + [ _x( 'EB', 'unit symbol' ) ]: EB_IN_BYTES, + /* translators: Unit symbol for petabyte. */ + [ _x( 'PB', 'unit symbol' ) ]: PB_IN_BYTES, + /* translators: Unit symbol for terabyte. */ + [ _x( 'TB', 'unit symbol' ) ]: TB_IN_BYTES, + /* translators: Unit symbol for gigabyte. */ + [ _x( 'GB', 'unit symbol' ) ]: GB_IN_BYTES, + /* translators: Unit symbol for megabyte. */ + [ _x( 'MB', 'unit symbol' ) ]: MB_IN_BYTES, + /* translators: Unit symbol for kilobyte. */ + [ _x( 'KB', 'unit symbol' ) ]: KB_IN_BYTES, + /* translators: Unit symbol for byte. */ + [ _x( 'B', 'unit symbol' ) ]: 1, + }; + + for ( const [ unit, mag ] of Object.entries( quant ) ) { + if ( bytes >= mag ) { + return getBytesString( bytes / mag, unit, decimals ); + } + } + + return ''; // Fallback in case no unit matches, though this should not happen. +} + +const filesizeField: Partial< Field< MediaItem > > = { + id: 'filesize', + type: 'text', + label: __( 'File size' ), + getValue: ( { item }: { item: MediaItem } ) => + item?.media_details?.filesize + ? formatFileSize( item?.media_details?.filesize ) + : '', + isVisible: ( item: MediaItem ) => { + return !! item?.media_details?.filesize; + }, + enableSorting: false, + filterBy: false, + readOnly: true, +}; + +export default filesizeField; diff --git a/packages/media-fields/src/index.ts b/packages/media-fields/src/index.ts new file mode 100644 index 00000000000000..06be4ca933066a --- /dev/null +++ b/packages/media-fields/src/index.ts @@ -0,0 +1,15 @@ +export { default as altTextField } from './alt_text'; +export { default as captionField } from './caption'; +export { default as descriptionField } from './description'; +export { default as filenameField } from './filename'; +export { default as filesizeField } from './filesize'; +export { default as mediaDimensionsField } from './media_dimensions'; +export { default as mediaThumbnailField } from './media_thumbnail'; +export { default as mimeTypeField } from './mime_type'; + +export type { + MediaItem, + MediaKind, + MediaType, + MediaItemUpdatable, +} from './types'; diff --git a/packages/media-fields/src/media_dimensions/index.ts b/packages/media-fields/src/media_dimensions/index.ts new file mode 100644 index 00000000000000..280acfe0a545d2 --- /dev/null +++ b/packages/media-fields/src/media_dimensions/index.ts @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { __, _x, sprintf } from '@wordpress/i18n'; +import type { Attachment, Updatable } from '@wordpress/core-data'; +import type { Field } from '@wordpress/dataviews'; + +const mediaDimensionsField: Partial< Field< Updatable< Attachment > > > = { + id: 'media_dimensions', + type: 'text', + label: __( 'Dimensions' ), + getValue: ( { item } ) => + item?.media_details?.width && item?.media_details?.height + ? sprintf( + // translators: 1: Width. 2: Height. + _x( '%1$s × %2$s', 'image dimensions' ), + item?.media_details?.width?.toString(), + item?.media_details?.height?.toString() + ) + : '', + isVisible: ( item ) => { + return !! ( item?.media_details?.width && item?.media_details?.height ); + }, + enableSorting: false, + filterBy: false, + readOnly: true, +}; + +export default mediaDimensionsField; diff --git a/packages/media-fields/src/media_thumbnail/index.tsx b/packages/media-fields/src/media_thumbnail/index.tsx new file mode 100644 index 00000000000000..b710a10b15dbc4 --- /dev/null +++ b/packages/media-fields/src/media_thumbnail/index.tsx @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import type { MediaItem } from '../types'; +import MediaThumbnailView from './view'; + +const mediaThumbnailField: Partial< Field< MediaItem > > = { + id: 'media_thumbnail', + type: 'media', + label: __( 'Thumbnail' ), + render: MediaThumbnailView, + enableSorting: false, + filterBy: false, +}; + +export default mediaThumbnailField; diff --git a/packages/media-fields/src/media_thumbnail/style.scss b/packages/media-fields/src/media_thumbnail/style.scss new file mode 100644 index 00000000000000..f7ea7ab291cefa --- /dev/null +++ b/packages/media-fields/src/media_thumbnail/style.scss @@ -0,0 +1,49 @@ +@use "@wordpress/base-styles/mixins"; +@use "@wordpress/base-styles/colors"; +@use "@wordpress/base-styles/variables"; + +.dataviews-media-field__media-thumbnail { + display: flex; + align-items: center; + position: relative; + height: 100%; +} + +.dataviews-media-field__media-thumbnail--image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.dataviews-media-field__media-thumbnail__stack { + color: colors.$gray-700; + box-sizing: border-box; + width: 100%; + height: 100%; +} + +.dataviews-media-field__media-thumbnail--icon { + color: colors.$gray-700; + fill: currentColor; +} + +.dataviews-media-field__media-thumbnail__filename { + box-sizing: border-box; + text-align: center; + padding: 0 variables.$grid-unit-20; + width: 100%; + container-type: inline-size; + @include mixins.body-small(); + + &__truncate { + // Use a top margin instead of gap on the parent, as the text will not + // always be rendered. This ensures the icon is vertically centered in all contexts. + margin-top: variables.$grid-unit-05; + + // Hide filename when previews are very small, as rendered in list views. + @container (max-width: 90px) { + display: none !important; + } + } +} diff --git a/packages/media-fields/src/media_thumbnail/view.tsx b/packages/media-fields/src/media_thumbnail/view.tsx new file mode 100644 index 00000000000000..ac8e662ed39cc0 --- /dev/null +++ b/packages/media-fields/src/media_thumbnail/view.tsx @@ -0,0 +1,104 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { + __experimentalTruncate as Truncate, + __experimentalVStack as VStack, + Icon, +} from '@wordpress/components'; +import type { Attachment } from '@wordpress/core-data'; +import { getFilename } from '@wordpress/url'; +import type { DataViewRenderFieldProps } from '@wordpress/dataviews'; +/** + * Internal dependencies + */ +import { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type'; +import type { MediaItem } from '../types'; + +export default function MediaThumbnailView( { + item, + config, +}: DataViewRenderFieldProps< MediaItem > ) { + const _featuredMedia = useSelect( + ( select ) => { + // Avoid the network request if it's not needed. `featured_media` is + // 0 for images and media without featured media. + if ( ! item.featured_media ) { + return; + } + return select( coreStore ).getEntityRecord< Attachment >( + 'postType', + 'attachment', + item.featured_media + ); + }, + [ item.featured_media ] + ); + const featuredMedia = item.featured_media ? _featuredMedia : item; + + // Fetching. + if ( ! featuredMedia ) { + return null; + } + + const filename = getFilename( featuredMedia.source_url || '' ); + + if ( + // Ensure the featured media is an image. + getMediaTypeFromMimeType( featuredMedia.mime_type ).type === 'image' + ) { + return ( +
+ + ) + .map( + ( size ) => + `${ size.source_url } ${ size.width }w` + ) + .join( ', ' ) + : undefined + } + sizes={ config?.sizes || '100vw' } + alt={ featuredMedia.alt_text || featuredMedia.title.raw } + /> +
+ ); + } + + return ( +
+ + + { !! filename && ( +
+ + { filename } + +
+ ) } +
+
+ ); +} diff --git a/packages/media-fields/src/mime_type/index.ts b/packages/media-fields/src/mime_type/index.ts new file mode 100644 index 00000000000000..949f4c7af7161b --- /dev/null +++ b/packages/media-fields/src/mime_type/index.ts @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import type { Attachment, Updatable } from '@wordpress/core-data'; +import type { Field } from '@wordpress/dataviews'; + +const mimeTypeField: Partial< Field< Updatable< Attachment > > > = { + id: 'mime_type', + type: 'text', + label: __( 'File type' ), + getValue: ( { item } ) => item?.mime_type || '', + render: ( { item } ) => item?.mime_type || '-', + enableSorting: true, + filterBy: false, + readOnly: true, +}; + +export default mimeTypeField; diff --git a/packages/media-fields/src/stories/index.story.tsx b/packages/media-fields/src/stories/index.story.tsx new file mode 100644 index 00000000000000..410fbe0a978246 --- /dev/null +++ b/packages/media-fields/src/stories/index.story.tsx @@ -0,0 +1,290 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { DataForm, DataViews, type Form } from '@wordpress/dataviews'; +import type { Field, View } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { + altTextField, + captionField, + descriptionField, + filenameField, + filesizeField, + mediaDimensionsField, + mediaThumbnailField, + mimeTypeField, + type MediaItem, +} from '../index'; + +export default { + title: 'Fields/Media Fields', + component: DataForm, +}; + +// Sample data for media fields +const sampleMediaItem: MediaItem = { + id: 123, + date: '2024-01-15T10:30:00', + date_gmt: '2024-01-15T10:30:00', + guid: { + raw: 'https://cldup.com/cXyG__fTLN.jpg', + rendered: 'https://cldup.com/cXyG__fTLN.jpg', + }, + modified: '2024-01-15T10:30:00', + modified_gmt: '2024-01-15T10:30:00', + slug: 'sample-image', + status: 'publish', + type: 'attachment', + link: 'https://example.com/sample-image/', + title: { + raw: 'Sample Image', + rendered: 'Sample Image', + }, + author: 1, + featured_media: 0, + comment_status: 'open', + ping_status: 'closed', + template: '', + meta: {}, + permalink_template: 'https://example.com/?attachment_id=123', + generated_slug: 'sample-image', + class_list: [ 'post-123', 'attachment' ], + alt_text: 'A beautiful sample image', + caption: { + raw: 'A caption for the image', + rendered: '

A caption for the image

\n', + }, + description: { + raw: 'This is a detailed description of the sample image. It contains useful information about what the image depicts and its context.', + rendered: + '

This is a detailed description of the sample image. It contains useful information about what the image depicts and its context.

', + }, + mime_type: 'image/jpeg', + media_type: 'image', + post: null, + source_url: 'https://cldup.com/cXyG__fTLN.jpg', + media_details: { + file: 'sample-image.jpg', + width: 1920, + height: 1080, + filesize: 524288, + image_meta: { + aperture: '2.8', + credit: '', + camera: 'Sample Camera', + caption: '', + created_timestamp: '1705315800', + copyright: '', + focal_length: '50', + iso: '100', + shutter_speed: '0.004', + title: '', + orientation: '1', + keywords: [], + }, + sizes: { + thumbnail: { + file: 'sample-image-150x150.jpg', + width: 150, + height: 150, + filesize: 8192, + mime_type: 'image/jpeg', + source_url: 'https://cldup.com/cXyG__fTLN.jpg', + }, + medium: { + file: 'sample-image-300x169.jpg', + width: 300, + height: 169, + filesize: 24576, + mime_type: 'image/jpeg', + source_url: 'https://cldup.com/cXyG__fTLN.jpg', + }, + }, + }, + missing_image_sizes: [], +}; + +// Sample data for a non-image file (ZIP) +const sampleMediaItemZip: MediaItem = { + id: 101, + date: '2025-11-07T00:28:54', + date_gmt: '2025-11-07T00:28:54', + guid: { + raw: 'http://localhost:8888/wp-content/uploads/2025/11/gutenberg-v22-0-0.zip', + rendered: + 'http://localhost:8888/wp-content/uploads/2025/11/gutenberg-v22-0-0.zip', + }, + modified: '2025-11-07T00:28:54', + modified_gmt: '2025-11-07T00:28:54', + slug: 'gutenberg-v22-0-0', + status: 'publish', + type: 'attachment', + link: 'http://localhost:8888/gutenberg-v22-0-0/', + title: { + raw: 'gutenberg-v22-0-0', + rendered: 'gutenberg-v22-0-0', + }, + author: 1, + featured_media: 0, + comment_status: 'open', + ping_status: 'closed', + template: '', + meta: {}, + permalink_template: 'http://localhost:8888/?attachment_id=101', + generated_slug: 'gutenberg-v22-0-0', + class_list: [ 'post-101', 'attachment' ], + alt_text: '', + caption: { + raw: '', + rendered: '

gutenberg-v22-0-0

\n', + }, + description: { + raw: '', + rendered: '', + }, + mime_type: 'application/zip', + media_type: 'file', + post: 1, + source_url: + 'http://localhost:8888/wp-content/uploads/2025/11/gutenberg-v22-0-0.zip', + media_details: { + file: 'gutenberg-v22-0-0.zip', + filesize: 19988723, + width: 0, + height: 0, + image_meta: { + aperture: '', + credit: '', + camera: '', + caption: '', + created_timestamp: '', + copyright: '', + focal_length: '', + iso: '', + shutter_speed: '', + title: '', + orientation: '', + keywords: [], + }, + sizes: {}, + }, + missing_image_sizes: [], +}; + +// Create a showcase of all media fields. +const showcaseFields = [ + mediaThumbnailField, + filenameField, + altTextField, + captionField, + descriptionField, + mimeTypeField, + mediaDimensionsField, + filesizeField, +] as Field< any >[]; + +const DataFormsComponent = ( { type }: { type: 'regular' | 'panel' } ) => { + const [ data, setData ] = useState< MediaItem >( sampleMediaItem ); + + const handleChange = ( updates: Partial< MediaItem > ) => { + setData( ( prev: MediaItem ) => ( { ...prev, ...updates } ) ); + }; + + // Form configuration for the media fields showcase. + const showcaseForm: Form = { + layout: { + type, + }, + fields: [ + 'media_thumbnail', + 'alt_text', + 'caption', + 'description', + 'filename', + 'mime_type', + 'media_dimensions', + 'filesize', + ], + }; + + return ( +
+

Media Fields

+

+ This story demonstrates all the media fields from the + @wordpress/media-fields package within a DataForm. +

+ + +
+ ); +}; + +export const DataFormsPreview = { + render: DataFormsComponent, + argTypes: { + type: { + control: { type: 'select' }, + description: 'Choose the layout type.', + options: [ 'regular', 'panel' ], + }, + }, + args: { + type: 'regular', + }, +}; + +export const DataViewsPreview = () => { + const [ view, setView ] = useState< View >( { + type: 'table', + fields: showcaseFields + .map( ( f ) => f.id ) + .filter( ( id ) => id !== 'media_thumbnail' ), + descriptionField: undefined, + mediaField: 'media_thumbnail', + showTitle: false, + } ); + const [ data ] = useState< MediaItem[] >( [ + sampleMediaItem, + sampleMediaItemZip, + ] ); + + const paginationInfo = { + totalItems: 2, + totalPages: 1, + }; + + const defaultLayouts = { + table: {}, + list: {}, + grid: {}, + }; + + return ( +
+

Media Fields DataViews Preview

+

+ This story demonstrates all the media fields from the + @wordpress/media-fields package, rendered in a DataViews + component, allowing preview of view state and layout switching. +

+ setView( nextView ) } + paginationInfo={ paginationInfo } + defaultLayouts={ defaultLayouts } + /> +
+ ); +}; diff --git a/packages/media-fields/src/style.scss b/packages/media-fields/src/style.scss new file mode 100644 index 00000000000000..d2426224c06c34 --- /dev/null +++ b/packages/media-fields/src/style.scss @@ -0,0 +1 @@ +@use "./media_thumbnail/style.scss" as *; diff --git a/packages/media-fields/src/types.ts b/packages/media-fields/src/types.ts new file mode 100644 index 00000000000000..8709db58d52e8f --- /dev/null +++ b/packages/media-fields/src/types.ts @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import type { Attachment, Updatable, Post } from '@wordpress/core-data'; + +export type MediaKind = 'image' | 'video' | 'audio' | 'application'; + +export interface MediaType { + type: MediaKind; + label: string; + icon: JSX.Element; +} + +// TODO: Update the Attachment type separately. +export interface MediaItem extends Attachment< 'edit' > { + // featured_media is not in the Attachment type. See https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entity-types/attachment.ts#L10 + featured_media: number; + _embedded?: { + // TODO: Include wp:attached-to properly, and backport PHP changes from wordpress-develop to support this. + 'wp:attached-to'?: Post[] | Partial< Post >[]; + }; +} + +export type MediaItemUpdatable = Updatable< Attachment >; diff --git a/packages/media-fields/src/utils/get-media-type-from-mime-type.ts b/packages/media-fields/src/utils/get-media-type-from-mime-type.ts new file mode 100644 index 00000000000000..973620e9d46863 --- /dev/null +++ b/packages/media-fields/src/utils/get-media-type-from-mime-type.ts @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { audio, video, image, file } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { MediaType } from '../types'; + +/** + * Get the media type from a mime type, including an icon. + * TODO - media types should be formalized somewhere. + * + * References: + * https://developer.wordpress.org/reference/functions/wp_mime_type_icon/ + * https://developer.wordpress.org/reference/hooks/mime_types/ + * https://developer.wordpress.org/reference/functions/wp_get_mime_types/ + * + * @param mimeType - The mime type to get the media type from. + * @return The media type. + */ +export function getMediaTypeFromMimeType( mimeType: string ): MediaType { + if ( mimeType.startsWith( 'image/' ) ) { + return { + type: 'image', + label: __( 'Image' ), + icon: image, + }; + } + + if ( mimeType.startsWith( 'video/' ) ) { + return { + type: 'video', + label: __( 'Video' ), + icon: video, + }; + } + + if ( mimeType.startsWith( 'audio/' ) ) { + return { + type: 'audio', + label: __( 'Audio' ), + icon: audio, + }; + } + + return { + type: 'application', + label: __( 'Application' ), + icon: file, + }; +} diff --git a/packages/media-fields/src/utils/get-raw-content.ts b/packages/media-fields/src/utils/get-raw-content.ts new file mode 100644 index 00000000000000..fe24e6997cbe82 --- /dev/null +++ b/packages/media-fields/src/utils/get-raw-content.ts @@ -0,0 +1,32 @@ +/** + * Utility function to extract raw content from either a string or an object + * containing raw and rendered properties. + * + * This handles the inconsistency in WordPress REST API responses where + * some fields like caption and description can be either: + * - A simple string + * - An object with { raw: string, rendered: string } + * + * @param content - The content to extract raw value from + * @return The raw content string, or empty string if content is falsy + */ +export function getRawContent( + content: string | { raw: string; rendered: string } | undefined | null +): string { + if ( ! content ) { + return ''; + } + + // If it's a string, return it directly + if ( typeof content === 'string' ) { + return content; + } + + // If it's an object with raw property, return the raw value + if ( typeof content === 'object' && 'raw' in content ) { + return content.raw || ''; + } + + // Fallback to empty string + return ''; +} diff --git a/packages/media-fields/src/utils/get-rendered-content.ts b/packages/media-fields/src/utils/get-rendered-content.ts new file mode 100644 index 00000000000000..45248c038bf67b --- /dev/null +++ b/packages/media-fields/src/utils/get-rendered-content.ts @@ -0,0 +1,32 @@ +/** + * Utility function to extract rendered content from either a string or an object + * containing raw and rendered properties. + * + * This handles the inconsistency in WordPress REST API responses where + * some fields like caption and description can be either: + * - A simple string + * - An object with { raw: string, rendered: string } + * + * @param content - The content to extract raw value from + * @return The rendered content string, falling back to raw or empty string if content is falsy + */ +export function getRenderedContent( + content: string | { raw: string; rendered: string } | undefined | null +): string { + if ( ! content ) { + return ''; + } + + // If it's a string, return it directly + if ( typeof content === 'string' ) { + return content; + } + + // If it's an object with raw property, return the raw value + if ( typeof content === 'object' ) { + return content.rendered || content.raw || ''; + } + + // Fallback to empty string + return ''; +} diff --git a/packages/media-fields/tsconfig.json b/packages/media-fields/tsconfig.json new file mode 100644 index 00000000000000..97f7a88b282cbd --- /dev/null +++ b/packages/media-fields/tsconfig.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": [ + "gutenberg-env", + "gutenberg-test-env", + "jest", + "@testing-library/jest-dom" + ] + }, + "references": [ + { "path": "../components" }, + { "path": "../core-data" }, + { "path": "../data" }, + { "path": "../dataviews" }, + { "path": "../element" }, + { "path": "../i18n" }, + { "path": "../icons" }, + { "path": "../primitives" }, + { "path": "../url" } + ], + "exclude": [ + "src/**/*.android.js", + "src/**/*.ios.js", + "src/**/*.native.js", + "src/**/react-native-*", + "src/**/stories/**/*.js", // only exclude js files, tsx files should be checked + "src/**/test/**/*.js" // only exclude js files, ts{x} files should be checked + ] +} diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index 59316b3a04105f..4fd5fda26e7b15 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -35,6 +35,9 @@ }, "wpScript": true, "types": "build-types", + "sideEffects": [ + "build-style/**" + ], "dependencies": { "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", @@ -45,6 +48,7 @@ "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/media-fields": "file:../media-fields", "@wordpress/private-apis": "file:../private-apis" }, "peerDependencies": { diff --git a/packages/media-utils/src/components/media-upload-modal/index.tsx b/packages/media-utils/src/components/media-upload-modal/index.tsx index b0e20d9e8ac574..9b4f4b8b08bc63 100644 --- a/packages/media-utils/src/components/media-upload-modal/index.tsx +++ b/packages/media-utils/src/components/media-upload-modal/index.tsx @@ -10,12 +10,22 @@ import { import { resolveSelect } from '@wordpress/data'; import { Modal, DropZone, FormFileUpload, Button } from '@wordpress/components'; import { upload as uploadIcon } from '@wordpress/icons'; +import { DataViewsPicker } from '@wordpress/dataviews'; +import type { View, Field, ActionButton } from '@wordpress/dataviews'; +import { + altTextField, + captionField, + descriptionField, + filenameField, + filesizeField, + mediaDimensionsField, + mediaThumbnailField, + mimeTypeField, +} from '@wordpress/media-fields'; /** * Internal dependencies */ -import { DataViewsPicker } from '@wordpress/dataviews'; -import type { View, Field, ActionButton } from '@wordpress/dataviews'; import type { Attachment, RestAttachment } from '../../utils/types'; import { transformAttachment } from '../../utils/transform-attachment'; import { uploadMedia } from '../../utils/upload-media'; @@ -151,8 +161,9 @@ export function MediaUploadModal( { const [ view, setView ] = useState< View >( () => ( { type: LAYOUT_PICKER_GRID, fields: [], + showTitle: false, titleField: 'title', - mediaField: 'url', + mediaField: 'media_thumbnail', search: '', page: 1, perPage: 20, @@ -211,23 +222,6 @@ export function MediaUploadModal( { const fields: Field< RestAttachment >[] = useMemo( () => [ - { - id: 'url', - type: 'media' as const, - label: __( 'Media' ), - render: ( { item }: { item: RestAttachment } ) => ( - { - ), - }, { id: 'title', type: 'text' as const, @@ -237,13 +231,16 @@ export function MediaUploadModal( { return titleValue || __( '(no title)' ); }, }, - { - id: 'alt', - type: 'text' as const, - label: __( 'Alt text' ), - getValue: ( { item }: { item: RestAttachment } ) => - item.alt_text, - }, + // Media field definitions from @wordpress/media-fields + // Cast is safe because RestAttachment has the same properties as Attachment + mediaThumbnailField as Field< RestAttachment >, + altTextField as Field< RestAttachment >, + captionField as Field< RestAttachment >, + descriptionField as Field< RestAttachment >, + filenameField as Field< RestAttachment >, + filesizeField as Field< RestAttachment >, + mediaDimensionsField as Field< RestAttachment >, + mimeTypeField as Field< RestAttachment >, ], [] ); @@ -319,8 +316,14 @@ export function MediaUploadModal( { const defaultLayouts = useMemo( () => ( { - [ LAYOUT_PICKER_GRID ]: {}, - [ LAYOUT_PICKER_TABLE ]: {}, + [ LAYOUT_PICKER_GRID ]: { + fields: [], + showTitle: false, + }, + [ LAYOUT_PICKER_TABLE ]: { + fields: [ 'filename', 'filesize', 'media_dimensions' ], + showTitle: true, + }, } ), [] ); diff --git a/packages/media-utils/src/style.scss b/packages/media-utils/src/style.scss new file mode 100644 index 00000000000000..b96131f28a97af --- /dev/null +++ b/packages/media-utils/src/style.scss @@ -0,0 +1 @@ +@use "@wordpress/media-fields/build-style/style.css" as *; diff --git a/packages/media-utils/tsconfig.json b/packages/media-utils/tsconfig.json index 5d6d8272564eef..1ec018e909f6b6 100644 --- a/packages/media-utils/tsconfig.json +++ b/packages/media-utils/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../element" }, { "path": "../i18n" }, { "path": "../icons" }, + { "path": "../media-fields" }, { "path": "../private-apis" } ] } diff --git a/storybook/main.js b/storybook/main.js index 91da1025ac5fec..47d71dba2cfdfa 100644 --- a/storybook/main.js +++ b/storybook/main.js @@ -33,12 +33,13 @@ const stories = [ '../packages/block-editor/src/**/stories/*.story.@(js|jsx|tsx|mdx)', '../packages/components/src/**/stories/*.story.@(jsx|tsx)', '../packages/components/src/**/stories/*.mdx', - '../packages/icons/src/**/stories/*.story.@(jsx|tsx|mdx)', - '../packages/edit-site/src/**/stories/*.story.@(jsx|tsx|mdx)', - '../packages/global-styles-ui/src/**/stories/*.story.@(jsx|tsx|mdx)', - '../packages/dataviews/src/**/stories/*.story.@(jsx|tsx|mdx)', - '../packages/fields/src/**/stories/*.story.@(jsx|tsx|mdx)', + '../packages/icons/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/edit-site/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/global-styles-ui/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/dataviews/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/fields/src/**/stories/*.story.@(js|tsx|mdx)', '../packages/image-cropper/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/media-fields/src/**/stories/*.story.@(js|tsx|mdx)', '../packages/theme/src/**/stories/*.story.@(tsx|mdx)', '../packages/ui/src/**/stories/*.story.@(ts|tsx)', ].filter( Boolean ); diff --git a/storybook/package-styles/config.js b/storybook/package-styles/config.js index a682e751b000ce..285ae07bf1baf0 100644 --- a/storybook/package-styles/config.js +++ b/storybook/package-styles/config.js @@ -15,6 +15,8 @@ import dataviewsLtr from '../package-styles/dataviews-ltr.lazy.scss'; import dataviewsRtl from '../package-styles/dataviews-rtl.lazy.scss'; import fieldsLtr from '../package-styles/fields-ltr.lazy.scss'; import fieldsRtl from '../package-styles/fields-rtl.lazy.scss'; +import mediaFieldsLtr from '../package-styles/media-fields-ltr.lazy.scss'; +import mediaFieldsRtl from '../package-styles/media-fields-rtl.lazy.scss'; /** * Stylesheets to lazy load when the story's context.componentId matches the @@ -62,8 +64,8 @@ const CONFIG = [ }, { componentIdMatcher: /^fields-/, - ltr: [ componentsLtr, dataviewsLtr, fieldsLtr ], - rtl: [ componentsRtl, dataviewsRtl, fieldsRtl ], + ltr: [ componentsLtr, dataviewsLtr, fieldsLtr, mediaFieldsLtr ], + rtl: [ componentsRtl, dataviewsRtl, fieldsRtl, mediaFieldsRtl ], }, ]; diff --git a/storybook/package-styles/media-fields-ltr.lazy.scss b/storybook/package-styles/media-fields-ltr.lazy.scss new file mode 100644 index 00000000000000..9c1238af7eaec5 --- /dev/null +++ b/storybook/package-styles/media-fields-ltr.lazy.scss @@ -0,0 +1 @@ +@import "../../packages/media-fields/build-style/style.css"; diff --git a/storybook/package-styles/media-fields-rtl.lazy.scss b/storybook/package-styles/media-fields-rtl.lazy.scss new file mode 100644 index 00000000000000..88f2c2e710ec39 --- /dev/null +++ b/storybook/package-styles/media-fields-rtl.lazy.scss @@ -0,0 +1 @@ +@import "../../packages/media-fields/build-style/style-rtl.css"; diff --git a/tsconfig.json b/tsconfig.json index 05529ae60906ee..e29b1775e260dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,7 @@ { "path": "packages/latex-to-mathml" }, { "path": "packages/lazy-import" }, { "path": "packages/lazy-editor" }, + { "path": "packages/media-fields" }, { "path": "packages/media-utils" }, { "path": "packages/notices" }, { "path": "packages/plugins" },