From c7e5b7cbc94d2e0bcd7f38ffa3473352ad2a4bb3 Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Thu, 13 Oct 2022 11:15:15 +0300 Subject: [PATCH] [Inserter]: Add media tab --- .../components/inserter/media-tab/hooks.js | 135 ++++++++++++++++++ .../components/inserter/media-tab/index.js | 2 + .../inserter/media-tab/media-list.js | 130 +++++++++++++++++ .../inserter/media-tab/media-panel.js | 63 ++++++++ .../inserter/media-tab/media-tab.js | 119 +++++++++++++++ .../src/components/inserter/menu.js | 65 +++++++-- .../src/components/inserter/style.scss | 129 +++++++++++++++++ .../src/components/inserter/tabs.js | 11 +- packages/core-data/src/fetch/fetch-media.js | 13 ++ packages/core-data/src/fetch/index.js | 1 + .../provider/use-block-editor-settings.js | 4 + 11 files changed, 662 insertions(+), 10 deletions(-) create mode 100644 packages/block-editor/src/components/inserter/media-tab/hooks.js create mode 100644 packages/block-editor/src/components/inserter/media-tab/index.js create mode 100644 packages/block-editor/src/components/inserter/media-tab/media-list.js create mode 100644 packages/block-editor/src/components/inserter/media-tab/media-panel.js create mode 100644 packages/block-editor/src/components/inserter/media-tab/media-tab.js create mode 100644 packages/core-data/src/fetch/fetch-media.js diff --git a/packages/block-editor/src/components/inserter/media-tab/hooks.js b/packages/block-editor/src/components/inserter/media-tab/hooks.js new file mode 100644 index 0000000000000..88a00923c74db --- /dev/null +++ b/packages/block-editor/src/components/inserter/media-tab/hooks.js @@ -0,0 +1,135 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../../store'; + +// TODO: Even though the `mature` param is `false` by default, we might need to determine where +// and if there is other 'weird' content like in the title. It happened for me to test with `skate` +// and the first result contains a word in the title that might not be suitable for all users. +async function fetchFromOpenverse( { search, pageSize = 10 } ) { + const controller = new AbortController(); + const url = new URL( 'https://api.openverse.engineering/v1/images/' ); + // TODO: add licence filters etc.. + url.searchParams.set( 'q', search ); + url.searchParams.set( 'page_size', pageSize ); + const response = await window.fetch( url, { + headers: [ [ 'Content-Type', 'application/json' ] ], + signal: controller.signal, + } ); + return response.json(); +} + +export function useMediaResults( category, options = {} ) { + const [ results, setResults ] = useState( [] ); + const settings = useSelect( + ( select ) => select( blockEditorStore ).getSettings(), + [] + ); + const isOpenverse = category.name === 'openverse'; + useEffect( () => { + ( async () => { + // TODO: add loader probably and not set results.. + setResults( [] ); + try { + if ( isOpenverse ) { + const response = await fetchFromOpenverse( options ); + setResults( response.results ); + } else { + const _media = await settings?.__unstableFetchMedia( { + per_page: 7, + media_type: category.mediaType, + } ); + if ( _media ) setResults( _media ); + } + } catch ( error ) { + // TODO: handle this + throw error; + } + } )(); + }, [ category?.name, ...Object.values( options ) ] ); + + return results; +} + +// TODO: Need to think of the props.. :) +const MEDIA_CATEGORIES = [ + { label: __( 'Images' ), name: 'images', mediaType: 'image' }, + { label: __( 'Videos' ), name: 'videos', mediaType: 'video' }, + { label: __( 'Audio' ), name: 'audio', mediaType: 'audio' }, + { label: 'Openverse', name: 'openverse', mediaType: 'image' }, +]; +// TODO: definitely revisit the implementation and probably add a loader(return loading state).. +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 () => { + const query = { + context: 'view', + per_page: 1, + _fields: [ 'id' ], + }; + const [ showImage, showVideo, showAudio ] = await Promise.all( [ + canInsertImage && + !! ( + await fetchMedia( { + ...query, + media_type: 'image', + } ) + ).length, + canInsertVideo && + !! ( + await fetchMedia( { + ...query, + media_type: 'video', + } ) + ).length, + canInsertAudio && + !! ( + await fetchMedia( { + ...query, + media_type: 'audio', + } ) + ).length, + ] ); + setCategories( + MEDIA_CATEGORIES.filter( + ( { mediaType } ) => + ( mediaType === 'image' && showImage ) || + ( mediaType === 'video' && showVideo ) || + ( mediaType === 'audio' && showAudio ) + ) + ); + } )(); + }, [ canInsertImage, canInsertVideo, canInsertAudio, fetchMedia ] ); + return categories; +} diff --git a/packages/block-editor/src/components/inserter/media-tab/index.js b/packages/block-editor/src/components/inserter/media-tab/index.js new file mode 100644 index 0000000000000..3c944c6c709ef --- /dev/null +++ b/packages/block-editor/src/components/inserter/media-tab/index.js @@ -0,0 +1,2 @@ +export { default as MediaTab } from './media-tab'; +export { MediaCategoryDialog } from './media-panel'; diff --git a/packages/block-editor/src/components/inserter/media-tab/media-list.js b/packages/block-editor/src/components/inserter/media-tab/media-list.js new file mode 100644 index 0000000000000..78e28a51af432 --- /dev/null +++ b/packages/block-editor/src/components/inserter/media-tab/media-list.js @@ -0,0 +1,130 @@ +/** + * WordPress dependencies + */ +// import { useMemo, useCallback } from '@wordpress/element'; +// import { useInstanceId } from '@wordpress/compose'; +import { + __unstableComposite as Composite, + __unstableUseCompositeState as useCompositeState, + __unstableCompositeItem as CompositeItem, +} from '@wordpress/components'; +import { createBlock } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +/** + * Internal dependencies + */ +import InserterDraggableBlocks from '../../inserter-draggable-blocks'; +import BlockPreview from '../../block-preview'; + +function getBlocksPreview( media, mediaType, isOpenverse ) { + let attributes; + // TODO: check all the needed attributes(alt, caption, etc..) + if ( mediaType === 'image' ) { + attributes = isOpenverse + ? { url: media.thumbnail || media.url } + : { + id: media.id, + url: media.source_url, + }; + } else if ( mediaType === 'video' || mediaType === 'audio' ) { + attributes = { + id: media.id, + src: media.source_url, + }; + } + + const blocks = createBlock( `core/${ mediaType }`, attributes ); + return blocks; +} + +function MediaPreview( { media, onClick, composite, mediaType, isOpenverse } ) { + // TODO: Check caption or attribution, etc.. + // TODO: create different blocks per media type.. + const blocks = getBlocksPreview( media, mediaType, isOpenverse ); + // TODO: we have to set a max height for previews as the image can be very tall. + // Probably a fixed-max height for all(?). + const title = media.title?.rendered || media.title; + const baseCssClass = 'block-editor-inserter__media-list'; + // const descriptionId = useInstanceId( + // MediaPreview, + // `${ baseCssClass }__item-description` + // ); + return ( + + { ( { draggable, onDragStart, onDragEnd } ) => ( +
+ { + // TODO: We need to handle the case with focus to image's caption + // during insertion. This makes the inserter to close. + onClick( blocks ); + } } + aria-label={ title } + // aria-describedby={} + > + +
+ { title } +
+ { /* { !! description && ( + + { description } + + ) } */ } +
+
+ ) } +
+ ); +} + +function MediaList( { + isOpenverse, + results, + mediaType, + onClick, + label = __( 'Media List' ), +} ) { + const composite = useCompositeState(); + // const blocks = useMemo( () => { + // // TODO: Check caption or attribution, etc.. + // // TODO: create different blocks per media type.. + // // const blockss = getBlocksPreview( media, mediaType, isOpenverse ); + // // return blockss; + // }, [] ); + // const onClickItem = useCallback( () => {}, [] ); + return ( + + { results.map( ( media ) => ( + + ) ) } + + ); +} + +export default MediaList; diff --git a/packages/block-editor/src/components/inserter/media-tab/media-panel.js b/packages/block-editor/src/components/inserter/media-tab/media-panel.js new file mode 100644 index 0000000000000..60587867ea794 --- /dev/null +++ b/packages/block-editor/src/components/inserter/media-tab/media-panel.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { useRef, useEffect } from '@wordpress/element'; +import { __experimentalHeading as Heading } from '@wordpress/components'; +import { focus } from '@wordpress/dom'; + +/** + * Internal dependencies + */ +import MediaList from './media-list'; +import { useMediaResults } from './hooks'; + +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 ] ); + + const results = useMediaResults( category ); + + // const showOpenverse = category.name === 'openverse'; + // TODO: should probably get the media here..(?) + return ( +
+ +
+ ); +} + +export function MediaCategoryPanel( { + rootClientId, + onInsert, + category, + results, +} ) { + // TODO: add loader probably.. + return ( +
+ + { category.label } + + { !! results.length && ( + + ) } +
+ ); +} diff --git a/packages/block-editor/src/components/inserter/media-tab/media-tab.js b/packages/block-editor/src/components/inserter/media-tab/media-tab.js new file mode 100644 index 0000000000000..8d55ce41170f1 --- /dev/null +++ b/packages/block-editor/src/components/inserter/media-tab/media-tab.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useViewportMatch } from '@wordpress/compose'; +import { + __experimentalItemGroup as ItemGroup, + __experimentalItem as Item, + __experimentalHStack as HStack, + // __experimentalNavigatorProvider as NavigatorProvider, + // __experimentalNavigatorScreen as NavigatorScreen, + // __experimentalNavigatorButton as NavigatorButton, + // __experimentalNavigatorBackButton as NavigatorBackButton, + FlexBlock, + Button, + __unstableComposite as Composite, + __unstableUseCompositeState as useCompositeState, + __unstableCompositeItem as CompositeItem, +} from '@wordpress/components'; +import { Icon, chevronRight } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import MediaUploadCheck from '../../media-upload/check'; +import MediaUpload from '../../media-upload'; +import { useMediaCategories } from './hooks'; + +function MediaTab( { rootClientId, selectedCategory, onSelectCategory } ) { + // TODO: check here if media exists for each type('image,audio, etc..) and conditionally render the tabs. + // We should probably pass the media(?) - check with search implementation. + const mediaCategories = useMediaCategories( rootClientId ); + const isMobile = useViewportMatch( 'medium', '<' ); + const composite = useCompositeState(); + const baseCssClass = 'block-editor-inserter__media-tabs'; + return ( + <> + { ! isMobile && ( +
+ + { mediaCategories.map( ( mediaCategory ) => ( + + onSelectCategory( mediaCategory ) + } + className={ classNames( + `${ baseCssClass }__media-category`, + { + 'is-selected': + selectedCategory === mediaCategory, + } + ) } + aria-label={ mediaCategory.label } + > + + + { mediaCategory.label } + + + + + ) ) } + +
+ ) } + + selectMedia( media, onClose ) } + // onSelect={ ( _media ) => { + // const aaa = _media; + // } } + // mode={ 'browse' } + // allowedTypes={ [ 'image' ] } + render={ ( {} ) => ( + + ) } + /> + + { isMobile && ( + + ) } + + ); +} + +function MediaTabNavigation() { + return 'make mobile..'; +} + +export default MediaTab; diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js index 5a10d07dd1caa..024bcdd428c54 100644 --- a/packages/block-editor/src/components/inserter/menu.js +++ b/packages/block-editor/src/components/inserter/menu.js @@ -26,6 +26,7 @@ import InserterPreviewPanel from './preview-panel'; import BlockTypesTab from './block-types-tab'; import BlockPatternsTabs from './block-patterns-tab'; import ReusableBlocksTab from './reusable-blocks-tab'; +import { MediaTab, MediaCategoryDialog } from './media-tab'; import InserterSearchResults from './search-results'; import useInsertionPoint from './hooks/use-insertion-point'; import InserterTabs from './tabs'; @@ -52,6 +53,9 @@ function InserterMenu( const [ hoveredItem, setHoveredItem ] = useState( null ); const [ selectedPatternCategory, setSelectedPatternCategory ] = useState( null ); + const [ selectedTab, setSelectedTab ] = useState( null ); + const [ selectedMediaCategory, setSelectedMediaCategory ] = + useState( null ); const [ destinationRootClientId, onInsertBlocks, onToggleInsertionPoint ] = useInsertionPoint( { @@ -61,17 +65,31 @@ function InserterMenu( insertionIndex: __experimentalInsertionIndex, shouldFocusBlock, } ); - const { showPatterns, hasReusableBlocks } = useSelect( + const { showPatterns, hasReusableBlocks, showMedia } = useSelect( ( select ) => { - const { __experimentalGetAllowedPatterns, getSettings } = - select( blockEditorStore ); - + const { + __experimentalGetAllowedPatterns, + getSettings, + canInsertBlockType, + } = select( blockEditorStore ); + const { __experimentalReusableBlocks, __unstableFetchMedia } = + getSettings(); + // TODO: we should probably prefetch data(1 result) to check if there are actually any media for each type. + // Except for `image` as if it's allowed we still have Openverse. + const _showMedia = + !! __unstableFetchMedia && + [ 'image', 'video', 'audio' ].some( ( type ) => + canInsertBlockType( + `core/${ type }`, + destinationRootClientId + ) + ); return { showPatterns: !! __experimentalGetAllowedPatterns( destinationRootClientId ).length, - hasReusableBlocks: - !! getSettings().__experimentalReusableBlocks?.length, + hasReusableBlocks: !! __experimentalReusableBlocks?.length, + showMedia: _showMedia, }; }, [ destinationRootClientId ] @@ -167,16 +185,35 @@ function InserterMenu( [ destinationRootClientId, onInsert, onHover ] ); + const mediaTab = useMemo( + () => ( + + ), + [ + destinationRootClientId, + onInsert, + selectedMediaCategory, + setSelectedMediaCategory, + ] + ); + const getCurrentTab = useCallback( ( tab ) => { if ( tab.name === 'blocks' ) { return blocksTab; } else if ( tab.name === 'patterns' ) { return patternsTab; + } else if ( tab.name === 'reusable' ) { + return reusableBlocksTab; + } else if ( tab.name === 'media' ) { + return mediaTab; } - return reusableBlocksTab; }, - [ blocksTab, patternsTab, reusableBlocksTab ] + [ blocksTab, patternsTab, reusableBlocksTab, mediaTab ] ); const searchRef = useRef(); @@ -187,7 +224,8 @@ function InserterMenu( } ) ); const showAsTabs = ! filterValue && ( showPatterns || hasReusableBlocks ); - + const showMediaPanel = + selectedTab === 'media' && ! filterValue && selectedMediaCategory; return (
{ getCurrentTab } @@ -238,6 +278,13 @@ function InserterMenu(
) }
+ { showMediaPanel && ( + + ) } { showInserterHelpPanel && hoveredItem && ( ) } diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss index 32e5093233209..fb2ef4c828421 100644 --- a/packages/block-editor/src/components/inserter/style.scss +++ b/packages/block-editor/src/components/inserter/style.scss @@ -388,3 +388,132 @@ $block-inserter-tabs-height: 44px; } } } + +// TODO: cleanup styles.. +.block-editor-inserter__media-tabs { + flex-grow: 1; + + &__media-category { + &.is-selected { + color: var(--wp-admin-theme-color); + position: relative; + + .components-flex-item { + filter: brightness(0.95); + } + + svg { + fill: var(--wp-admin-theme-color); + } + + &::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + border-radius: $radius-block-ui; + opacity: 0.04; + background: var(--wp-admin-theme-color); + } + } + } +} + +.block-editor-inserter__media-tabs-container { + height: 100%; + display: flex; + flex-direction: column; + padding: $grid-unit-20; + overflow-y: auto; +} + +.block-editor-inserter__media-panel { + background: $gray-100; + border-left: $border-width solid $gray-200; + border-right: $border-width solid $gray-200; + position: absolute; + padding: $grid-unit-40 $grid-unit-30; + top: 0; + left: 0; + height: 100%; + width: 100%; + overflow-y: auto; + scrollbar-gutter: stable both-edges; + + @include break-medium { + left: 100%; + display: block; + width: 300px; + } + + .block-editor-block-card { + padding: $grid-unit-20; + } + + .block-editor-block-card__title { + font-size: $default-font-size; + } + + h3 { + margin-top: 0; + } + + .block-editor-block-preview__container { + box-shadow: 0 15px 25px rgb(0 0 0 / 7%); + &:hover { + box-shadow: 0 0 0 2px $gray-900, 0 15px 25px rgb(0 0 0 / 7%); + } + } +} + +.block-editor-inserter__media-list { + margin-top: $grid-unit-20; + &__list-item { + cursor: pointer; + margin-bottom: $grid-unit-30; + + &.is-placeholder { + min-height: 100px; + } + + &[draggable="true"] .block-editor-block-preview__container { + cursor: grab; + } + } + + &__item { + height: 100%; + + .block-editor-block-preview__container { + display: flex; + align-items: center; + overflow: hidden; + border-radius: 4px; + } + + // .block-editor-block-patterns-list__item-title { + // padding-top: $grid-unit-10; + // font-size: 12px; + // text-align: center; + // } + + &:hover .block-editor-block-preview__container { + box-shadow: 0 0 0 2px var(--wp-admin-theme-color); + } + + &:focus .block-editor-block-preview__container { + box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + } + + + // &:hover .block-editor-block-patterns-list__item-title, + // &:focus .block-editor-block-patterns-list__item-title { + // color: var(--wp-admin-theme-color); + // } + } +} diff --git a/packages/block-editor/src/components/inserter/tabs.js b/packages/block-editor/src/components/inserter/tabs.js index afc1c74e0a280..9ca369b992dcf 100644 --- a/packages/block-editor/src/components/inserter/tabs.js +++ b/packages/block-editor/src/components/inserter/tabs.js @@ -20,11 +20,17 @@ const reusableBlocksTab = { /* translators: Reusable blocks tab title in the block inserter. */ title: __( 'Reusable' ), }; +const mediaTab = { + name: 'media', + /* translators: Media tab title in the block inserter. */ + title: __( 'Media' ), +}; function InserterTabs( { children, showPatterns = false, showReusableBlocks = false, + showMedia = false, onSelect, prioritizePatterns, } ) { @@ -40,7 +46,9 @@ function InserterTabs( { if ( showReusableBlocks ) { tempTabs.push( reusableBlocksTab ); } - + if ( showMedia ) { + tempTabs.push( mediaTab ); + } return tempTabs; }, [ prioritizePatterns, @@ -48,6 +56,7 @@ function InserterTabs( { showPatterns, patternsTab, showReusableBlocks, + showMedia, reusableBlocksTab, ] ); diff --git a/packages/core-data/src/fetch/fetch-media.js b/packages/core-data/src/fetch/fetch-media.js new file mode 100644 index 0000000000000..c7a3a429a8627 --- /dev/null +++ b/packages/core-data/src/fetch/fetch-media.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { resolveSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME as coreStore } from '../name'; + +export default async function fetchMedia( settings = {} ) { + return resolveSelect( coreStore ).getMediaItems( settings ); +} diff --git a/packages/core-data/src/fetch/index.js b/packages/core-data/src/fetch/index.js index 8d4d28e3b0db8..2f765825f3e70 100644 --- a/packages/core-data/src/fetch/index.js +++ b/packages/core-data/src/fetch/index.js @@ -1,2 +1,3 @@ export { default as __experimentalFetchLinkSuggestions } from './__experimental-fetch-link-suggestions'; export { default as __experimentalFetchUrlData } from './__experimental-fetch-url-data'; +export { default as __experimentalFetchMedia } from './fetch-media'; diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 51d16ef1b6a7d..8fc20e3aa6f22 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -12,6 +12,7 @@ import { store as coreStore, __experimentalFetchLinkSuggestions as fetchLinkSuggestions, __experimentalFetchUrlData as fetchUrlData, + __experimentalFetchMedia as fetchMedia, } from '@wordpress/core-data'; import { __ } from '@wordpress/i18n'; @@ -183,6 +184,9 @@ function useBlockEditorSettings( settings, hasTemplate ) { __experimentalBlockPatternCategories: blockPatternCategories, __experimentalFetchLinkSuggestions: ( search, searchOptions ) => fetchLinkSuggestions( search, searchOptions, settings ), + // TODO: We should find a proper way to consolidate similar cases + // like reusable blocks, fetch entities, etc. + __unstableFetchMedia: ( _settings ) => fetchMedia( _settings ), __experimentalFetchRichUrlData: fetchUrlData, __experimentalCanUserUseUnfilteredHTML: canUseUnfilteredHTML, __experimentalUndo: undo,