Skip to content

Commit

Permalink
[Inserter]: Add media tab (#44918)
Browse files Browse the repository at this point in the history
* [Inserter]: Add media tab

* fix mobile styles

* fix media library modal

* change media tab order

* add tooltip, style tweaks and cleanup

* memo preview blocks

* clone media block before insertion

* use only css for `open media library` positioning

* use raw caption

* inject styles to the block preview iframe content

* remove `BlockPreview` usage

* fix media library in safari

* fix focus outside when media library button has focus

* Update packages/block-editor/src/components/inserter/media-tab/media-tab.js

Co-authored-by: Matias Ventura <mv@matiasventura.com>

* preload initial media requests

* remove AsyncList and css fix

* Apply suggestions from code review

Co-authored-by: Miguel Fonseca <miguelcsf@gmail.com>

* add e2e test and small tweaks

Co-authored-by: Matias Ventura <mv@matiasventura.com>
Co-authored-by: Miguel Fonseca <miguelcsf@gmail.com>
  • Loading branch information
3 people committed Nov 25, 2022
1 parent 92aa6c8 commit d40cc5c
Show file tree
Hide file tree
Showing 17 changed files with 811 additions and 7 deletions.
24 changes: 24 additions & 0 deletions lib/compat/wordpress-6.2/edit-form-blocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
/**
* Patches resources loaded by the block editor page.
*
* @package gutenberg
*/

/**
* Adds the preload paths registered in Core (`edit-form-blocks.php`).
*
* @param array $preload_paths Preload paths to be filtered.
* @param WP_Block_Editor_Context $context The current block editor context.
* @return array
*/
// @codingStandardsIgnoreStart - unused $context parameter.
function gutenberg_preload_paths_6_2( $preload_paths, $context ) {
// Preload initial media requests that are needed to conditionally display the media tab in the inserter.
foreach ( array( 'image', 'video', 'audio' ) as $media_type ) {
$preload_paths[] = "wp/v2/media?context=view&per_page=1&_fields=id&media_type={$media_type}";
}

return $preload_paths;
}
add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_preload_paths_6_2', 10, 2 );
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-6.2/default-filters.php';
require __DIR__ . '/compat/wordpress-6.2/class-wp-theme-json-resolver-6-2.php';
require __DIR__ . '/compat/wordpress-6.2/class-wp-theme-json-6-2.php';
require __DIR__ . '/compat/wordpress-6.2/edit-form-blocks.php';

// Experimental features.
remove_action( 'plugins_loaded', '_wp_theme_json_webfonts_handler' ); // Turns off WP 6.0's stopgap handler for Webfonts API.
Expand Down
101 changes: 101 additions & 0 deletions packages/block-editor/src/components/inserter/media-tab/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEffect, useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useDebounce } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';

export function useDebouncedInput() {
const [ input, setInput ] = useState( '' );
const [ debounced, setter ] = useState( '' );
const setDebounced = useDebounce( setter, 250 );
useEffect( () => {
if ( debounced !== input ) {
setDebounced( input );
}
}, [ debounced, input ] );
return [ input, setInput, debounced ];
}

export function useMediaResults( options = {} ) {
const [ results, setResults ] = useState();
const settings = useSelect(
( select ) => select( blockEditorStore ).getSettings(),
[]
);
useEffect( () => {
( async () => {
setResults();
const _media = await settings?.__unstableFetchMedia( options );
if ( _media ) setResults( _media );
} )();
}, Object.values( options ) );
return results;
}

const MEDIA_CATEGORIES = [
{ label: __( 'Images' ), name: 'images', mediaType: 'image' },
{ label: __( 'Videos' ), name: 'videos', mediaType: 'video' },
{ label: __( 'Audio' ), name: 'audio', mediaType: 'audio' },
];
export function useMediaCategories( rootClientId ) {
const [ categories, setCategories ] = useState( [] );
const { canInsertImage, canInsertVideo, canInsertAudio, fetchMedia } =
useSelect(
( select ) => {
const { canInsertBlockType, getSettings } =
select( blockEditorStore );
return {
fetchMedia: getSettings().__unstableFetchMedia,
canInsertImage: canInsertBlockType(
'core/image',
rootClientId
),
canInsertVideo: canInsertBlockType(
'core/video',
rootClientId
),
canInsertAudio: canInsertBlockType(
'core/audio',
rootClientId
),
};
},
[ rootClientId ]
);
useEffect( () => {
( async () => {
// If `__unstableFetchMedia` is not defined in block
// editor settings, do not set any media categories.
if ( ! fetchMedia ) return;
const query = {
context: 'view',
per_page: 1,
_fields: [ 'id' ],
};
const [ image, video, audio ] = await Promise.all( [
fetchMedia( { ...query, media_type: 'image' } ),
fetchMedia( { ...query, media_type: 'video' } ),
fetchMedia( { ...query, media_type: 'audio' } ),
] );
const showImage = canInsertImage && !! image.length;
const showVideo = canInsertVideo && !! video.length;
const showAudio = canInsertAudio && !! audio.length;
setCategories(
MEDIA_CATEGORIES.filter(
( { mediaType } ) =>
( mediaType === 'image' && showImage ) ||
( mediaType === 'video' && showVideo ) ||
( mediaType === 'audio' && showAudio )
)
);
} )();
}, [ canInsertImage, canInsertVideo, canInsertAudio, fetchMedia ] );
return categories;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as MediaTab } from './media-tab';
export { MediaCategoryDialog } from './media-panel';
export { useMediaCategories } from './hooks';
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* WordPress dependencies
*/
import {
__unstableComposite as Composite,
__unstableUseCompositeState as useCompositeState,
__unstableCompositeItem as CompositeItem,
Tooltip,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useMemo, useCallback } from '@wordpress/element';
import { cloneBlock } from '@wordpress/blocks';

/**
* Internal dependencies
*/
import InserterDraggableBlocks from '../../inserter-draggable-blocks';
import { getBlockAndPreviewFromMedia } from './utils';

function MediaPreview( { media, onClick, composite, mediaType } ) {
const [ block, preview ] = useMemo(
() => getBlockAndPreviewFromMedia( media, mediaType ),
[ media, mediaType ]
);
const title = media.title?.rendered || media.title;
const baseCssClass = 'block-editor-inserter__media-list';
return (
<InserterDraggableBlocks isEnabled={ true } blocks={ [ block ] }>
{ ( { draggable, onDragStart, onDragEnd } ) => (
<div
className={ `${ baseCssClass }__list-item` }
draggable={ draggable }
onDragStart={ onDragStart }
onDragEnd={ onDragEnd }
>
<Tooltip text={ title }>
<CompositeItem
role="option"
as="div"
{ ...composite }
className={ `${ baseCssClass }__item` }
onClick={ () => {
onClick( block );
} }
aria-label={ title }
>
<div
className={ `${ baseCssClass }__item-preview` }
>
{ preview }
</div>
</CompositeItem>
</Tooltip>
</div>
) }
</InserterDraggableBlocks>
);
}

function MediaList( {
mediaList,
mediaType,
onClick,
label = __( 'Media List' ),
} ) {
const composite = useCompositeState();
const onPreviewClick = useCallback(
( block ) => {
onClick( cloneBlock( block ) );
},
[ onClick ]
);
return (
<Composite
{ ...composite }
role="listbox"
className="block-editor-inserter__media-list"
aria-label={ label }
>
{ mediaList.map( ( media ) => (
<MediaPreview
key={ media.id }
media={ media }
mediaType={ mediaType }
onClick={ onPreviewClick }
composite={ composite }
/>
) ) }
</Composite>
);
}

export default MediaList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* WordPress dependencies
*/
import { useRef, useEffect } from '@wordpress/element';
import { Spinner, SearchControl } from '@wordpress/components';
import { focus } from '@wordpress/dom';
import { __, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import MediaList from './media-list';
import { useMediaResults, useDebouncedInput } from './hooks';
import InserterNoResults from '../no-results';

const INITIAL_MEDIA_ITEMS_PER_PAGE = 10;

export function MediaCategoryDialog( { rootClientId, onInsert, category } ) {
const container = useRef();
useEffect( () => {
const timeout = setTimeout( () => {
const [ firstTabbable ] = focus.tabbable.find( container.current );
firstTabbable?.focus();
} );
return () => clearTimeout( timeout );
}, [ category ] );
return (
<div ref={ container } className="block-editor-inserter__media-dialog">
<MediaCategoryPanel
rootClientId={ rootClientId }
onInsert={ onInsert }
category={ category }
/>
</div>
);
}

export function MediaCategoryPanel( { rootClientId, onInsert, category } ) {
const [ search, setSearch, debouncedSearch ] = useDebouncedInput();
const mediaList = useMediaResults( {
per_page: !! debouncedSearch ? 20 : INITIAL_MEDIA_ITEMS_PER_PAGE,
media_type: category.mediaType,
search: debouncedSearch,
orderBy: !! debouncedSearch ? 'relevance' : 'date',
} );
const baseCssClass = 'block-editor-inserter__media-panel';
return (
<div className={ baseCssClass }>
<SearchControl
className={ `${ baseCssClass }-search` }
onChange={ setSearch }
value={ search }
label={ sprintf(
/* translators: %s: Name of the media category(ex. 'images, videos'). */
__( 'Search %s' ),
category.label.toLocaleLowerCase()
) }
placeholder={ sprintf(
/* translators: %s: Name of the media category(ex. 'images, videos'). */
__( 'Search %s' ),
category.label.toLocaleLowerCase()
) }
/>
{ ! mediaList && (
<div className={ `${ baseCssClass }-spinner` }>
<Spinner />
</div>
) }
{ Array.isArray( mediaList ) && ! mediaList.length && (
<InserterNoResults />
) }
{ !! mediaList?.length && (
<MediaList
rootClientId={ rootClientId }
onClick={ onInsert }
mediaList={ mediaList }
mediaType={ category.mediaType }
/>
) }
</div>
);
}
Loading

0 comments on commit d40cc5c

Please sign in to comment.