Plugin Tabs noticias

This commit is contained in:
root
2026-04-14 13:50:04 -06:00
parent 299099d006
commit 19d08e5694
2334 changed files with 628926 additions and 113 deletions

View File

@@ -0,0 +1,126 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "areoi/media-grid-image",
"title": "Image",
"category": "areoi-components",
"parent": [ "areoi/media-grid" ],
"usesContext": [ "allowResize", "imageCrop", "fixedHeight" ],
"description": "Insert an image to make a visual statement.",
"keywords": [ "img", "photo", "picture" ],
"textdomain": "default",
"example": {
"attributes": {
"preview": true
}
},
"attributes": {
"preview": {
"type": "boolean",
"default": false
},
"block_id": {
"type": "string",
"default": null
},
"parent_id": {
"type": "string",
"default": null
},
"align": {
"type": "string"
},
"media_fit": {
"type": "string",
"default": "cover"
},
"media_height": {
"type": "string",
"default": "50"
},
"media_width": {
"type": "string",
"default": "100"
},
"media_align": {
"type": "string",
"default": "center"
},
"url": {
"type": "string"
},
"alt": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "alt",
"default": ""
},
"caption": {
"type": "string",
"source": "html",
"selector": "figcaption"
},
"title": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "title"
},
"href": {
"type": "string",
"source": "attribute",
"selector": "figure > a",
"attribute": "href"
},
"rel": {
"type": "string",
"source": "attribute",
"selector": "figure > a",
"attribute": "rel"
},
"linkClass": {
"type": "string",
"source": "attribute",
"selector": "figure > a",
"attribute": "class"
},
"id": {
"type": "number"
},
"width": {
"type": "number"
},
"height": {
"type": "number"
},
"sizeSlug": {
"type": "string"
},
"linkDestination": {
"type": "string"
},
"linkTarget": {
"type": "string",
"source": "attribute",
"selector": "figure > a",
"attribute": "target"
}
},
"supports": {
"anchor": true,
"color": {
"__experimentalDuotone": "img",
"text": false,
"background": false
},
"__experimentalBorder": {
"radius": true,
"__experimentalDefaultControls": {
"radius": true
}
}
},
"editorStyle": "wp-block-image-editor",
"style": "wp-block-image"
}

View File

@@ -0,0 +1,8 @@
export const MIN_SIZE = 20;
export const LINK_DESTINATION_NONE = 'none';
export const LINK_DESTINATION_MEDIA = 'media';
export const LINK_DESTINATION_ATTACHMENT = 'attachment';
export const LINK_DESTINATION_CUSTOM = 'custom';
export const NEW_TAB_REL = [ 'noreferrer', 'noopener' ];
export const ALLOWED_MEDIA_TYPES = [ 'image' ];
export const MEDIA_ID_NO_FEATURED_IMAGE_SET = 0;

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -0,0 +1,298 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { isEmpty } from 'lodash';
/**
* WordPress dependencies
*/
import { RichText, useBlockProps } from '@wordpress/block-editor';
const blockAttributes = {
align: {
type: 'string',
},
url: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'src',
},
alt: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'alt',
default: '',
},
caption: {
type: 'string',
source: 'html',
selector: 'figcaption',
},
href: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'href',
},
rel: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'rel',
},
linkClass: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'class',
},
id: {
type: 'number',
},
width: {
type: 'number',
},
height: {
type: 'number',
},
linkDestination: {
type: 'string',
},
linkTarget: {
type: 'string',
source: 'attribute',
selector: 'figure > a',
attribute: 'target',
},
};
const blockSupports = {
anchor: true,
color: {
__experimentalDuotone: 'img',
text: false,
background: false,
},
__experimentalBorder: {
radius: true,
__experimentalDefaultControls: {
radius: true,
},
},
};
const deprecated = [
{
attributes: {
...blockAttributes,
title: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'title',
},
sizeSlug: {
type: 'string',
},
},
supports: blockSupports,
save( { attributes } ) {
const {
url,
alt,
caption,
align,
href,
rel,
linkClass,
width,
height,
id,
linkTarget,
sizeSlug,
title,
} = attributes;
const newRel = isEmpty( rel ) ? undefined : rel;
const classes = classnames( {
[ `align${ align }` ]: align,
[ `size-${ sizeSlug }` ]: sizeSlug,
'is-resized': width || height,
} );
const image = (
<img
src={ url }
alt={ alt }
className={ id ? `wp-image-${ id }` : null }
width={ width }
height={ height }
title={ title }
/>
);
const figure = (
<>
{ href ? (
<a
className={ linkClass }
href={ href }
target={ linkTarget }
rel={ newRel }
>
{ image }
</a>
) : (
image
) }
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
value={ caption }
/>
) }
</>
);
if ( 'left' === align || 'right' === align || 'center' === align ) {
return (
<div { ...useBlockProps.save() }>
<figure className={ classes }>{ figure }</figure>
</div>
);
}
return (
<figure { ...useBlockProps.save( { className: classes } ) }>
{ figure }
</figure>
);
},
},
{
attributes: blockAttributes,
save( { attributes } ) {
const {
url,
alt,
caption,
align,
href,
width,
height,
id,
} = attributes;
const classes = classnames( {
[ `align${ align }` ]: align,
'is-resized': width || height,
} );
const image = (
<img
src={ url }
alt={ alt }
className={ id ? `wp-image-${ id }` : null }
width={ width }
height={ height }
/>
);
return (
<figure className={ classes }>
{ href ? <a href={ href }>{ image }</a> : image }
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
value={ caption }
/>
) }
</figure>
);
},
},
{
attributes: blockAttributes,
save( { attributes } ) {
const {
url,
alt,
caption,
align,
href,
width,
height,
id,
} = attributes;
const image = (
<img
src={ url }
alt={ alt }
className={ id ? `wp-image-${ id }` : null }
width={ width }
height={ height }
/>
);
return (
<figure className={ align ? `align${ align }` : null }>
{ href ? <a href={ href }>{ image }</a> : image }
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
value={ caption }
/>
) }
</figure>
);
},
},
{
attributes: blockAttributes,
save( { attributes } ) {
const {
url,
alt,
caption,
align,
href,
width,
height,
} = attributes;
const extraImageProps = width || height ? { width, height } : {};
const image = (
<img src={ url } alt={ alt } { ...extraImageProps } />
);
let figureStyle = {};
if ( width ) {
figureStyle = { width };
} else if ( align === 'left' || align === 'right' ) {
figureStyle = { maxWidth: '50%' };
}
return (
<figure
className={ align ? `align${ align }` : null }
style={ figureStyle }
>
{ href ? <a href={ href }>{ image }</a> : image }
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
value={ caption }
/>
) }
</figure>
);
},
},
];
export default deprecated;

View File

@@ -0,0 +1,433 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { get, has, omit, pick } from 'lodash';
import * as areoi from '../_components/Core.js';
/**
* WordPress dependencies
*/
import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob';
import { withNotices } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import {
BlockAlignmentControl,
BlockControls,
BlockIcon,
MediaPlaceholder,
useBlockProps,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { useEffect, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { image as icon } from '@wordpress/icons';
/* global wp */
/**
* Internal dependencies
*/
import Image from './image';
/**
* Module constants
*/
import {
LINK_DESTINATION_ATTACHMENT,
LINK_DESTINATION_CUSTOM,
LINK_DESTINATION_MEDIA,
LINK_DESTINATION_NONE,
ALLOWED_MEDIA_TYPES,
} from './constants';
export const pickRelevantMediaFiles = ( image, size ) => {
const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] );
imageProps.url =
get( image, [ 'sizes', size, 'url' ] ) ||
get( image, [ 'media_details', 'sizes', size, 'source_url' ] ) ||
image.url;
return imageProps;
};
/**
* Is the URL a temporary blob URL? A blob URL is one that is used temporarily
* while the image is being uploaded and will not have an id yet allocated.
*
* @param {number=} id The id of the image.
* @param {string=} url The url of the image.
*
* @return {boolean} Is the URL a Blob URL
*/
const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url );
/**
* Is the url for the image hosted externally. An externally hosted image has no
* id and is not a blob url.
*
* @param {number=} id The id of the image.
* @param {string=} url The url of the image.
*
* @return {boolean} Is the url an externally hosted url?
*/
export const isExternalImage = ( id, url ) => url && ! id && ! isBlobURL( url );
/**
* Checks if WP generated default image size. Size generation is skipped
* when the image is smaller than the said size.
*
* @param {Object} image
* @param {string} defaultSize
*
* @return {boolean} Whether or not it has default image size.
*/
function hasDefaultSize( image, defaultSize ) {
return (
has( image, [ 'sizes', defaultSize, 'url' ] ) ||
has( image, [ 'media_details', 'sizes', defaultSize, 'source_url' ] )
);
}
/**
* Checks if a media attachment object has been "destroyed",
* that is, removed from the media library. The core Media Library
* adds a `destroyed` property to a deleted attachment object in the media collection.
*
* @param {number} id The attachment id.
*
* @return {boolean} Whether the image has been destroyed.
*/
export function isMediaDestroyed( id ) {
const attachment = wp?.media?.attachment( id ) || {};
return attachment.destroyed;
}
export function ImageEdit( {
attributes,
setAttributes,
isSelected,
className,
noticeUI,
insertBlocksAfter,
noticeOperations,
onReplace,
context,
clientId,
} ) {
const {
url,
alt,
caption,
align,
id,
width,
height,
sizeSlug,
} = attributes;
const [ temporaryURL, setTemporaryURL ] = useState();
const altRef = useRef();
useEffect( () => {
altRef.current = alt;
}, [ alt ] );
const captionRef = useRef();
useEffect( () => {
captionRef.current = caption;
}, [ caption ] );
const ref = useRef();
const { imageDefaultSize, mediaUpload } = useSelect( ( select ) => {
const { getSettings } = select( blockEditorStore );
return pick( getSettings(), [ 'imageDefaultSize', 'mediaUpload' ] );
}, [] );
// A callback passed to MediaUpload,
// fired when the media modal closes.
function onCloseModal() {
if ( isMediaDestroyed( attributes?.id ) ) {
setAttributes( {
url: undefined,
id: undefined,
} );
}
}
/*
Runs an error callback if the image does not load.
If the error callback is triggered, we infer that that image
has been deleted.
*/
function onImageError( isReplaced = false ) {
// If the image block was not replaced with an embed,
// clear the attributes and trigger the placeholder.
if ( ! isReplaced ) {
setAttributes( {
url: undefined,
id: undefined,
} );
}
}
function onUploadError( message ) {
noticeOperations.removeAllNotices();
noticeOperations.createErrorNotice( message );
setAttributes( {
src: undefined,
id: undefined,
url: undefined,
} );
setTemporaryURL( undefined );
}
function onSelectImage( media ) {
if ( ! media || ! media.url ) {
setAttributes( {
url: undefined,
alt: undefined,
id: undefined,
title: undefined,
caption: undefined,
} );
return;
}
if ( isBlobURL( media.url ) ) {
setTemporaryURL( media.url );
return;
}
setTemporaryURL();
let mediaAttributes = pickRelevantMediaFiles( media, imageDefaultSize );
// If a caption text was meanwhile written by the user,
// make sure the text is not overwritten by empty captions.
if ( captionRef.current && ! get( mediaAttributes, [ 'caption' ] ) ) {
mediaAttributes = omit( mediaAttributes, [ 'caption' ] );
}
let additionalAttributes;
// Reset the dimension attributes if changing to a different image.
if ( ! media.id || media.id !== id ) {
additionalAttributes = {
width: undefined,
height: undefined,
// Fallback to size "full" if there's no default image size.
// It means the image is smaller, and the block will use a full-size URL.
sizeSlug: hasDefaultSize( media, imageDefaultSize )
? imageDefaultSize
: 'full',
};
} else {
// Keep the same url when selecting the same file, so "Image Size"
// option is not changed.
additionalAttributes = { url };
}
// Check if default link setting should be used.
let linkDestination = attributes.linkDestination;
if ( ! linkDestination ) {
// Use the WordPress option to determine the proper default.
// The constants used in Gutenberg do not match WP options so a little more complicated than ideal.
// TODO: fix this in a follow up PR, requires updating media-text and ui component.
switch (
wp?.media?.view?.settings?.defaultProps?.link ||
LINK_DESTINATION_NONE
) {
case 'file':
case LINK_DESTINATION_MEDIA:
linkDestination = LINK_DESTINATION_MEDIA;
break;
case 'post':
case LINK_DESTINATION_ATTACHMENT:
linkDestination = LINK_DESTINATION_ATTACHMENT;
break;
case LINK_DESTINATION_CUSTOM:
linkDestination = LINK_DESTINATION_CUSTOM;
break;
case LINK_DESTINATION_NONE:
linkDestination = LINK_DESTINATION_NONE;
break;
}
}
// Check if the image is linked to it's media.
let href;
switch ( linkDestination ) {
case LINK_DESTINATION_MEDIA:
href = media.url;
break;
case LINK_DESTINATION_ATTACHMENT:
href = media.link;
break;
}
mediaAttributes.href = href;
setAttributes( {
...mediaAttributes,
...additionalAttributes,
linkDestination,
} );
}
function onSelectURL( newURL ) {
if ( newURL !== url ) {
setAttributes( {
url: newURL,
id: undefined,
width: undefined,
height: undefined,
sizeSlug: imageDefaultSize,
} );
}
}
function updateAlignment( nextAlign ) {
const extraUpdatedAttributes = [ 'wide', 'full' ].includes( nextAlign )
? { width: undefined, height: undefined }
: {};
setAttributes( {
...extraUpdatedAttributes,
align: nextAlign,
} );
}
let isTemp = isTemporaryImage( id, url );
// Upload a temporary image on mount.
useEffect( () => {
if ( ! isTemp ) {
return;
}
const file = getBlobByURL( url );
if ( file ) {
mediaUpload( {
filesList: [ file ],
onFileChange: ( [ img ] ) => {
onSelectImage( img );
},
allowedTypes: ALLOWED_MEDIA_TYPES,
onError: ( message ) => {
isTemp = false;
onUploadError( message );
},
} );
}
}, [] );
// If an image is temporary, revoke the Blob url when it is uploaded (and is
// no longer temporary).
useEffect( () => {
if ( isTemp ) {
setTemporaryURL( url );
return;
}
revokeBlobURL( temporaryURL );
}, [ isTemp, url ] );
const isExternal = isExternalImage( id, url );
const src = isExternal ? url : undefined;
var fit_class = attributes['media_fit'] ? attributes['media_fit'] : 'cover';
var align_class = attributes['media_align'] ? attributes['media_align'] : 'center';
var media_height = attributes['media_fit'] && attributes['media_fit'] == 'set' ? ( attributes['media_height'] ? attributes['media_height'] : '50' ) : false;
var media_width = attributes['media_fit'] && attributes['media_fit'] == 'set' ? ( attributes['media_width'] ? attributes['media_width'] : '100' ) : false;
var style = {};
if ( media_height ) {
style['max-height'] = media_height + 'px';
}
if ( media_width ) {
style['max-width'] = media_width + 'px';
}
const mediaPreview = !! url && (
<div class="areoi-media position-relative">
<div class={ 'areoi-media-container ' + fit_class + ' ' + align_class }>
<img
alt={ __( 'Edit image' ) }
title={ __( 'Edit image' ) }
className={ 'edit-image-preview' }
src={ url }
style={ style }
/>
</div>
</div>
);
const classes = classnames( className, {
'is-transient': temporaryURL,
'is-resized': !! width || !! height,
[ `size-${ sizeSlug }` ]: sizeSlug,
} );
const blockProps = useBlockProps( {
ref,
className: classes + ' areoi-content-grid-item',
} );
function onChange( key, value ) {
setAttributes( { [key]: value } );
}
return (
<>
{ areoi.DisplayPreview( areoi, attributes, onChange, 'media-grid-image' ) }
{ !attributes.preview &&
<figure { ...blockProps }>
{ ( temporaryURL || url ) && (
<Image
temporaryURL={ temporaryURL }
attributes={ attributes }
setAttributes={ setAttributes }
isSelected={ isSelected }
insertBlocksAfter={ insertBlocksAfter }
onReplace={ onReplace }
onSelectImage={ onSelectImage }
onSelectURL={ onSelectURL }
onUploadError={ onUploadError }
containerRef={ ref }
context={ context }
clientId={ clientId }
onCloseModal={ onCloseModal }
onImageLoadError={ onImageError }
/>
) }
{ ! url && (
<BlockControls group="block">
<BlockAlignmentControl
value={ align }
onChange={ updateAlignment }
/>
</BlockControls>
) }
<MediaPlaceholder
icon={ <BlockIcon icon={ icon } /> }
onSelect={ onSelectImage }
onSelectURL={ onSelectURL }
notices={ noticeUI }
onError={ onUploadError }
onClose={ onCloseModal }
accept="image/*"
allowedTypes={ ALLOWED_MEDIA_TYPES }
value={ { id, src } }
mediaPreview={ mediaPreview }
disableMediaButtons={ temporaryURL || url }
/>
</figure>
}
</>
);
}
export default withNotices( ImageEdit );

View File

@@ -0,0 +1,896 @@
/**
* External dependencies
*/
import { View, TouchableWithoutFeedback } from 'react-native';
import { useRoute } from '@react-navigation/native';
/**
* WordPress dependencies
*/
import { Component, useEffect } from '@wordpress/element';
import {
requestMediaImport,
mediaUploadSync,
requestImageFailedRetryDialog,
requestImageUploadCancelDialog,
requestImageFullscreenPreview,
setFeaturedImage,
} from '@wordpress/react-native-bridge';
import {
Icon,
PanelBody,
ToolbarButton,
ToolbarGroup,
Image,
WIDE_ALIGNMENTS,
LinkSettingsNavigation,
BottomSheet,
BottomSheetTextControl,
BottomSheetSelectControl,
FooterMessageControl,
FooterMessageLink,
Badge,
} from '@wordpress/components';
import {
BlockCaption,
MediaPlaceholder,
MediaUpload,
MediaUploadProgress,
MEDIA_TYPE_IMAGE,
BlockControls,
InspectorControls,
BlockAlignmentToolbar,
BlockStyles,
store as blockEditorStore,
blockSettingsScreens,
} from '@wordpress/block-editor';
import { __, _x, sprintf } from '@wordpress/i18n';
import { getProtocol, hasQueryArg } from '@wordpress/url';
import { doAction, hasAction } from '@wordpress/hooks';
import { compose, withPreferredColorScheme } from '@wordpress/compose';
import { withSelect, withDispatch } from '@wordpress/data';
import {
image as placeholderIcon,
replace,
fullscreen,
textColor,
} from '@wordpress/icons';
import { store as coreStore } from '@wordpress/core-data';
import { store as editPostStore } from '@wordpress/edit-post';
/**
* Internal dependencies
*/
import styles from './styles.scss';
import { getUpdatedLinkTargetSettings } from './utils';
import {
LINK_DESTINATION_NONE,
LINK_DESTINATION_CUSTOM,
LINK_DESTINATION_ATTACHMENT,
LINK_DESTINATION_MEDIA,
MEDIA_ID_NO_FEATURED_IMAGE_SET,
} from './constants';
const getUrlForSlug = ( image, sizeSlug ) => {
if ( ! sizeSlug ) {
return undefined;
}
return image?.media_details?.sizes?.[ sizeSlug ]?.source_url;
};
function LinkSettings( {
attributes,
image,
isLinkSheetVisible,
setMappedAttributes,
} ) {
const route = useRoute();
const { href: url, label, linkDestination, linkTarget, rel } = attributes;
// Persist attributes passed from child screen.
useEffect( () => {
const { inputValue: newUrl } = route.params || {};
let newLinkDestination;
switch ( newUrl ) {
case attributes.url:
newLinkDestination = LINK_DESTINATION_MEDIA;
break;
case image?.link:
newLinkDestination = LINK_DESTINATION_ATTACHMENT;
break;
case '':
newLinkDestination = LINK_DESTINATION_NONE;
break;
default:
newLinkDestination = LINK_DESTINATION_CUSTOM;
break;
}
setMappedAttributes( {
url: newUrl,
linkDestination: newLinkDestination,
} );
}, [ route.params?.inputValue ] );
let valueMask;
switch ( linkDestination ) {
case LINK_DESTINATION_MEDIA:
valueMask = __( 'Media File' );
break;
case LINK_DESTINATION_ATTACHMENT:
valueMask = __( 'Attachment Page' );
break;
case LINK_DESTINATION_CUSTOM:
valueMask = __( 'Custom URL' );
break;
default:
valueMask = __( 'None' );
break;
}
const linkSettingsOptions = {
url: {
valueMask,
autoFocus: false,
autoFill: false,
},
openInNewTab: {
label: __( 'Open in new tab' ),
},
linkRel: {
label: __( 'Link Rel' ),
placeholder: _x( 'None', 'Link rel attribute value placeholder' ),
},
};
return (
<PanelBody title={ __( 'Link Settings' ) }>
<LinkSettingsNavigation
isVisible={ isLinkSheetVisible }
url={ url }
rel={ rel }
label={ label }
linkTarget={ linkTarget }
setAttributes={ setMappedAttributes }
withBottomSheet={ false }
hasPicker
options={ linkSettingsOptions }
showIcon={ false }
onLinkCellPressed={ ( { navigation } ) => {
navigation.navigate(
blockSettingsScreens.imageLinkDestinations,
{
inputValue: attributes.href,
linkDestination: attributes.linkDestination,
imageUrl: attributes.url,
attachmentPageUrl: image?.link,
}
);
} }
/>
</PanelBody>
);
}
const UPLOAD_STATE_IDLE = 0;
const UPLOAD_STATE_UPLOADING = 1;
const UPLOAD_STATE_SUCCEEDED = 2;
const UPLOAD_STATE_FAILED = 3;
export class ImageEdit extends Component {
constructor( props ) {
super( props );
this.state = {
isCaptionSelected: false,
uploadStatus: UPLOAD_STATE_IDLE,
isAnimatedGif: false,
};
this.replacedFeaturedImage = false;
this.finishMediaUploadWithSuccess = this.finishMediaUploadWithSuccess.bind(
this
);
this.finishMediaUploadWithFailure = this.finishMediaUploadWithFailure.bind(
this
);
this.mediaUploadStateReset = this.mediaUploadStateReset.bind( this );
this.onSelectMediaUploadOption = this.onSelectMediaUploadOption.bind(
this
);
this.updateMediaProgress = this.updateMediaProgress.bind( this );
this.updateImageURL = this.updateImageURL.bind( this );
this.onSetNewTab = this.onSetNewTab.bind( this );
this.onSetSizeSlug = this.onSetSizeSlug.bind( this );
this.onImagePressed = this.onImagePressed.bind( this );
this.onSetFeatured = this.onSetFeatured.bind( this );
this.onFocusCaption = this.onFocusCaption.bind( this );
this.updateAlignment = this.updateAlignment.bind( this );
this.accessibilityLabelCreator = this.accessibilityLabelCreator.bind(
this
);
this.setMappedAttributes = this.setMappedAttributes.bind( this );
this.onSizeChangeValue = this.onSizeChangeValue.bind( this );
}
componentDidMount() {
const { attributes, setAttributes } = this.props;
// This will warn when we have `id` defined, while `url` is undefined.
// This may help track this issue: https://github.com/wordpress-mobile/WordPress-Android/issues/9768
// where a cancelled image upload was resulting in a subsequent crash.
if ( attributes.id && ! attributes.url ) {
// eslint-disable-next-line no-console
console.warn( 'Attributes has id with no url.' );
}
// Detect any pasted image and start an upload.
if (
! attributes.id &&
attributes.url &&
getProtocol( attributes.url ) === 'file:'
) {
requestMediaImport( attributes.url, ( id, url ) => {
if ( url ) {
setAttributes( { id, url } );
}
} );
}
// Make sure we mark any temporary images as failed if they failed while
// the editor wasn't open.
if (
attributes.id &&
attributes.url &&
getProtocol( attributes.url ) === 'file:'
) {
mediaUploadSync();
}
}
componentWillUnmount() {
// This action will only exist if the user pressed the trash button on the block holder.
if (
hasAction( 'blocks.onRemoveBlockCheckUpload' ) &&
this.state.uploadStatus === UPLOAD_STATE_UPLOADING
) {
doAction(
'blocks.onRemoveBlockCheckUpload',
this.props.attributes.id
);
}
}
componentDidUpdate( previousProps ) {
const {
image,
attributes,
setAttributes,
featuredImageId,
} = this.props;
if ( ! previousProps.image && image ) {
const url =
getUrlForSlug( image, attributes?.sizeSlug ) ||
image.source_url;
setAttributes( { url } );
}
const { id } = attributes;
const { id: previousId } = previousProps.attributes;
// The media changed and the previous media was set as the Featured Image,
// we must keep track of the previous media's featured status to act on it
// once the new media has a finalized ID.
if (
!! id &&
id !== previousId &&
!! featuredImageId &&
featuredImageId === previousId
) {
this.replacedFeaturedImage = true;
}
// The media changed and now has a finalized ID (e.g. upload completed), we
// should attempt to replace the featured image if applicable.
if (
this.replacedFeaturedImage &&
!! image &&
this.canImageBeFeatured()
) {
this.replacedFeaturedImage = false;
setFeaturedImage( id );
}
}
static getDerivedStateFromProps( props, state ) {
// Avoid a UI flicker in the toolbar by insuring that isCaptionSelected
// is updated immediately any time the isSelected prop becomes false.
return {
isCaptionSelected: props.isSelected && state.isCaptionSelected,
};
}
accessibilityLabelCreator( caption ) {
// Checks if caption is empty.
return ( typeof caption === 'string' && caption.trim().length === 0 ) ||
caption === undefined ||
caption === null
? /* translators: accessibility text. Empty image caption. */
'Image caption. Empty'
: sprintf(
/* translators: accessibility text. %s: image caption. */
__( 'Image caption. %s' ),
caption
);
}
onImagePressed() {
const { attributes, image } = this.props;
if ( this.state.uploadStatus === UPLOAD_STATE_UPLOADING ) {
requestImageUploadCancelDialog( attributes.id );
} else if (
attributes.id &&
getProtocol( attributes.url ) === 'file:'
) {
requestImageFailedRetryDialog( attributes.id );
} else if ( ! this.state.isCaptionSelected ) {
requestImageFullscreenPreview(
attributes.url,
image && image.source_url
);
}
this.setState( {
isCaptionSelected: false,
} );
}
updateMediaProgress( payload ) {
const { setAttributes } = this.props;
if ( payload.mediaUrl ) {
setAttributes( { url: payload.mediaUrl } );
}
if ( this.state.uploadStatus !== UPLOAD_STATE_UPLOADING ) {
this.setState( { uploadStatus: UPLOAD_STATE_UPLOADING } );
}
}
finishMediaUploadWithSuccess( payload ) {
const { setAttributes } = this.props;
setAttributes( { url: payload.mediaUrl, id: payload.mediaServerId } );
this.setState( { uploadStatus: UPLOAD_STATE_SUCCEEDED } );
this.setState( {
isAnimatedGif: payload.mediaUrl.toLowerCase().includes( '.gif' ),
} );
}
finishMediaUploadWithFailure( payload ) {
const { setAttributes } = this.props;
setAttributes( { id: payload.mediaId } );
this.setState( { uploadStatus: UPLOAD_STATE_FAILED } );
}
mediaUploadStateReset() {
const { setAttributes } = this.props;
setAttributes( { id: null, url: null } );
this.setState( { uploadStatus: UPLOAD_STATE_IDLE } );
}
updateImageURL( url ) {
this.props.setAttributes( {
url,
width: undefined,
height: undefined,
} );
}
updateAlignment( nextAlign ) {
const extraUpdatedAttributes = Object.values(
WIDE_ALIGNMENTS.alignments
).includes( nextAlign )
? { width: undefined, height: undefined }
: {};
this.props.setAttributes( {
...extraUpdatedAttributes,
align: nextAlign,
} );
}
onSetNewTab( value ) {
const updatedLinkTarget = getUpdatedLinkTargetSettings(
value,
this.props.attributes
);
this.props.setAttributes( updatedLinkTarget );
}
onSetSizeSlug( sizeSlug ) {
const { image, setAttributes } = this.props;
const url = getUrlForSlug( image, sizeSlug );
if ( ! url ) {
return null;
}
setAttributes( {
url,
width: undefined,
height: undefined,
sizeSlug,
} );
}
onSelectMediaUploadOption( media ) {
const { imageDefaultSize } = this.props;
const { id, url, destination } = this.props.attributes;
const mediaAttributes = {
id: media.id,
url: media.url,
caption: media.caption,
};
let additionalAttributes;
// Reset the dimension attributes if changing to a different image.
if ( ! media.id || media.id !== id ) {
additionalAttributes = {
width: undefined,
height: undefined,
sizeSlug: imageDefaultSize,
};
} else {
// Keep the same url when selecting the same file, so "Image Size" option is not changed.
additionalAttributes = { url };
}
let href;
switch ( destination ) {
case LINK_DESTINATION_MEDIA:
href = media.url;
break;
case LINK_DESTINATION_ATTACHMENT:
href = media.link;
break;
}
mediaAttributes.href = href;
this.props.setAttributes( {
...mediaAttributes,
...additionalAttributes,
} );
this.setState( {
isAnimatedGif: media.url.toLowerCase().includes( '.gif' ),
} );
}
onFocusCaption() {
if ( this.props.onFocus ) {
this.props.onFocus();
}
if ( ! this.state.isCaptionSelected ) {
this.setState( {
isCaptionSelected: true,
} );
}
}
getPlaceholderIcon() {
return (
<Icon
icon={ placeholderIcon }
{ ...this.props.getStylesFromColorScheme(
styles.iconPlaceholder,
styles.iconPlaceholderDark
) }
/>
);
}
getWidth() {
const { attributes } = this.props;
const { align, width } = attributes;
return Object.values( WIDE_ALIGNMENTS.alignments ).includes( align )
? '100%'
: width;
}
setMappedAttributes( { url: href, linkDestination, ...restAttributes } ) {
const { setAttributes } = this.props;
if ( ! href && ! linkDestination ) {
linkDestination = LINK_DESTINATION_NONE;
} else if ( ! linkDestination ) {
linkDestination = LINK_DESTINATION_CUSTOM;
}
return href === undefined || href === this.props.attributes.href
? setAttributes( restAttributes )
: setAttributes( {
...restAttributes,
linkDestination,
href,
} );
}
getAltTextSettings() {
const {
attributes: { alt },
} = this.props;
const updateAlt = ( newAlt ) => {
this.props.setAttributes( { alt: newAlt } );
};
return (
<BottomSheetTextControl
initialValue={ alt }
onChange={ updateAlt }
placeholder={ __( 'Add alt text' ) }
label={ __( 'Alt Text' ) }
icon={ textColor }
footerNote={
<>
{ __(
'Describe the purpose of the image. Leave empty if the image is purely decorative.'
) }
<FooterMessageLink
href={
'https://www.w3.org/WAI/tutorials/images/decision-tree/'
}
value={ __( 'What is alt text?' ) }
/>
</>
}
/>
);
}
onSizeChangeValue( newValue ) {
this.onSetSizeSlug( newValue );
}
onSetFeatured( mediaId ) {
const { closeSettingsBottomSheet } = this.props;
setFeaturedImage( mediaId );
closeSettingsBottomSheet();
}
getFeaturedButtonPanel( isFeaturedImage ) {
const { attributes, getStylesFromColorScheme } = this.props;
const setFeaturedButtonStyle = getStylesFromColorScheme(
styles.setFeaturedButton,
styles.setFeaturedButtonDark
);
const removeFeaturedButton = () => (
<BottomSheet.Cell
label={ __( 'Remove as Featured Image' ) }
labelStyle={ [
setFeaturedButtonStyle,
styles.removeFeaturedButton,
] }
cellContainerStyle={ styles.setFeaturedButtonCellContainer }
separatorType={ 'none' }
onPress={ () =>
this.onSetFeatured( MEDIA_ID_NO_FEATURED_IMAGE_SET )
}
/>
);
const setFeaturedButton = () => (
<BottomSheet.Cell
label={ __( 'Set as Featured Image' ) }
labelStyle={ setFeaturedButtonStyle }
cellContainerStyle={ styles.setFeaturedButtonCellContainer }
separatorType={ 'none' }
onPress={ () => this.onSetFeatured( attributes.id ) }
/>
);
return isFeaturedImage ? removeFeaturedButton() : setFeaturedButton();
}
/**
* Featured images must be set to a successfully uploaded self-hosted image,
* which has an ID.
*
* @return {boolean} Boolean indicating whether or not the current may be set as featured.
*/
canImageBeFeatured() {
const {
attributes: { id },
} = this.props;
return (
typeof id !== 'undefined' &&
this.state.uploadStatus !== UPLOAD_STATE_UPLOADING &&
this.state.uploadStatus !== UPLOAD_STATE_FAILED
);
}
render() {
const { isCaptionSelected } = this.state;
const {
attributes,
isSelected,
image,
clientId,
imageDefaultSize,
context,
featuredImageId,
wasBlockJustInserted,
} = this.props;
const { align, url, alt, id, sizeSlug, className } = attributes;
const hasImageContext = context
? Object.keys( context ).length > 0
: false;
const imageSizes = Array.isArray( this.props.imageSizes )
? this.props.imageSizes
: [];
// Only map available image sizes for the user to choose.
const sizeOptions = imageSizes
.filter( ( { slug } ) => getUrlForSlug( image, slug ) )
.map( ( { name, slug } ) => ( { value: slug, label: name } ) );
let selectedSizeOption = sizeSlug || imageDefaultSize;
let sizeOptionsValid = sizeOptions.find(
( option ) => option.value === selectedSizeOption
);
if ( ! sizeOptionsValid ) {
// Default to 'full' size if the default large size is not available.
sizeOptionsValid = sizeOptions.find(
( option ) => option.value === 'full'
);
selectedSizeOption = 'full';
}
const canImageBeFeatured = this.canImageBeFeatured();
const isFeaturedImage =
canImageBeFeatured && featuredImageId === attributes.id;
const getToolbarEditButton = ( open ) => (
<BlockControls>
<ToolbarGroup>
<ToolbarButton
title={ __( 'Edit image' ) }
icon={ replace }
onClick={ open }
/>
</ToolbarGroup>
<BlockAlignmentToolbar
value={ align }
onChange={ this.updateAlignment }
/>
</BlockControls>
);
const getInspectorControls = () => (
<InspectorControls>
<PanelBody title={ __( 'Image settings' ) } />
<PanelBody style={ styles.panelBody }>
<BlockStyles clientId={ clientId } url={ url } />
</PanelBody>
<PanelBody>
{ image && sizeOptionsValid && (
<BottomSheetSelectControl
icon={ fullscreen }
label={ __( 'Size' ) }
options={ sizeOptions }
onChange={ this.onSizeChangeValue }
value={ selectedSizeOption }
/>
) }
{ this.getAltTextSettings() }
</PanelBody>
<LinkSettings
attributes={ this.props.attributes }
image={ this.props.image }
isLinkSheetVisible={ this.state.isLinkSheetVisible }
setMappedAttributes={ this.setMappedAttributes }
/>
<PanelBody
title={ __( 'Featured Image' ) }
titleStyle={ styles.featuredImagePanelTitle }
>
{ canImageBeFeatured &&
this.getFeaturedButtonPanel( isFeaturedImage ) }
<FooterMessageControl
label={ __(
'Changes to featured image will not be affected by the undo/redo buttons.'
) }
cellContainerStyle={
styles.setFeaturedButtonCellContainer
}
/>
</PanelBody>
</InspectorControls>
);
if ( ! url ) {
return (
<View style={ styles.content }>
<MediaPlaceholder
allowedTypes={ [ MEDIA_TYPE_IMAGE ] }
onSelect={ this.onSelectMediaUploadOption }
icon={ this.getPlaceholderIcon() }
onFocus={ this.props.onFocus }
autoOpenMediaUpload={
isSelected && wasBlockJustInserted
}
/>
</View>
);
}
const alignToFlex = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
full: 'center',
wide: 'center',
};
const additionalImageProps = {
height: '100%',
resizeMode: context?.imageCrop ? 'cover' : 'contain',
};
const imageContainerStyles = [
context?.fixedHeight && styles.fixedHeight,
];
const badgeLabelShown = isFeaturedImage || this.state.isAnimatedGif;
let badgeLabelText = '';
if ( isFeaturedImage ) {
badgeLabelText = __( 'Featured' );
} else if ( this.state.isAnimatedGif ) {
badgeLabelText = __( 'GIF' );
}
const getImageComponent = ( openMediaOptions, getMediaOptions ) => (
<Badge label={ badgeLabelText } show={ badgeLabelShown }>
<TouchableWithoutFeedback
accessible={ ! isSelected }
onPress={ this.onImagePressed }
onLongPress={ openMediaOptions }
disabled={ ! isSelected }
>
<View style={ styles.content }>
{ isSelected && getInspectorControls() }
{ isSelected && getMediaOptions() }
{ ! this.state.isCaptionSelected &&
getToolbarEditButton( openMediaOptions ) }
<MediaUploadProgress
coverUrl={ url }
mediaId={ id }
onUpdateMediaProgress={ this.updateMediaProgress }
onFinishMediaUploadWithSuccess={
this.finishMediaUploadWithSuccess
}
onFinishMediaUploadWithFailure={
this.finishMediaUploadWithFailure
}
onMediaUploadStateReset={
this.mediaUploadStateReset
}
renderContent={ ( {
isUploadInProgress,
isUploadFailed,
retryMessage,
} ) => {
return (
<View style={ imageContainerStyles }>
<Image
align={
align && alignToFlex[ align ]
}
alt={ alt }
isSelected={
isSelected &&
! isCaptionSelected
}
isUploadFailed={ isUploadFailed }
isUploadInProgress={
isUploadInProgress
}
onSelectMediaUploadOption={
this.onSelectMediaUploadOption
}
openMediaOptions={
openMediaOptions
}
retryMessage={ retryMessage }
url={ url }
shapeStyle={
styles[ className ] || className
}
width={ this.getWidth() }
{ ...( hasImageContext
? additionalImageProps
: {} ) }
/>
</View>
);
} }
/>
</View>
</TouchableWithoutFeedback>
<BlockCaption
clientId={ this.props.clientId }
isSelected={ this.state.isCaptionSelected }
accessible
accessibilityLabelCreator={ this.accessibilityLabelCreator }
onFocus={ this.onFocusCaption }
onBlur={ this.props.onBlur } // Always assign onBlur as props.
insertBlocksAfter={ this.props.insertBlocksAfter }
/>
</Badge>
);
return (
<MediaUpload
allowedTypes={ [ MEDIA_TYPE_IMAGE ] }
isReplacingMedia={ true }
onSelect={ this.onSelectMediaUploadOption }
render={ ( { open, getMediaOptions } ) => {
return getImageComponent( open, getMediaOptions );
} }
/>
);
}
}
export default compose( [
withSelect( ( select, props ) => {
const { getMedia } = select( coreStore );
const { getSettings, wasBlockJustInserted } = select(
blockEditorStore
);
const { getEditedPostAttribute } = select( 'core/editor' );
const {
attributes: { id, url },
isSelected,
clientId,
} = props;
const { imageSizes, imageDefaultSize } = getSettings();
const isNotFileUrl = id && getProtocol( url ) !== 'file:';
const featuredImageId = getEditedPostAttribute( 'featured_media' );
const shouldGetMedia =
( isSelected && isNotFileUrl ) ||
// Edge case to update the image after uploading if the block gets unselected
// Check if it's the original image and not the resized one with queryparams.
( ! isSelected &&
isNotFileUrl &&
url &&
! hasQueryArg( url, 'w' ) );
return {
image: shouldGetMedia ? getMedia( id ) : null,
imageSizes,
imageDefaultSize,
featuredImageId,
wasBlockJustInserted: wasBlockJustInserted(
clientId,
'inserter_menu'
),
};
} ),
withDispatch( ( dispatch ) => {
return {
closeSettingsBottomSheet() {
dispatch( editPostStore ).closeGeneralSidebar();
},
};
} ),
withPreferredColorScheme,
] )( ImageEdit );

View File

@@ -0,0 +1,140 @@
figure.wp-block-image:not(.wp-block) {
margin: 0;
}
.wp-block-image {
position: relative;
.is-applying img,
&.is-transient img {
opacity: 0.3;
}
figcaption img {
display: inline;
}
// Shown while image is being uploaded
.components-spinner {
position: absolute;
top: 50%;
left: 50%;
margin-top: -9px;
margin-left: -9px;
}
&:not(.is-style-rounded) > div:not(.components-placeholder) {
border-radius: inherit;
}
}
// This is necessary for the editor resize handles to accurately work on a non-floated, non-resized, small image.
.wp-block-image .components-resizable-box__container {
display: inline-block;
img {
display: block;
width: inherit;
height: inherit;
}
}
.block-editor-block-list__block[data-type="core/image"] .block-editor-block-toolbar .block-editor-url-input__button-modal {
position: absolute;
left: 0;
right: 0;
margin: -$border-width 0;
@include break-small() {
margin: -$border-width;
}
}
[data-align="wide"] > .wp-block-image,
[data-align="full"] > .wp-block-image {
img {
height: auto;
width: 100%;
}
}
.wp-block[data-align="left"],
.wp-block[data-align="center"],
.wp-block[data-align="right"] {
> .wp-block-image {
display: table;
> figcaption {
display: table-caption;
caption-side: bottom;
}
}
}
.wp-block[data-align="left"] > .wp-block-image {
margin-right: 1em;
margin-left: 0;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.wp-block[data-align="right"] > .wp-block-image {
margin-left: 1em;
margin-right: 0;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.wp-block[data-align="center"] > .wp-block-image {
margin-left: auto;
margin-right: auto;
text-align: center;
}
.wp-block-image__crop-area {
position: relative;
max-width: 100%;
width: 100%;
}
.wp-block-image__crop-icon {
padding: 0 8px;
min-width: 48px;
display: flex;
justify-content: center;
align-items: center;
svg {
fill: currentColor;
}
}
.wp-block-image__zoom {
.components-popover__content {
overflow: visible;
min-width: 260px;
}
.components-range-control {
flex: 1;
}
.components-base-control__field {
display: flex;
margin-bottom: 0;
flex-direction: column;
align-items: flex-start;
}
}
.wp-block-image__aspect-ratio {
height: $grid-unit-60 - $border-width - $border-width;
margin-bottom: -$grid-unit-10;
display: flex;
align-items: center;
.components-button {
width: $button-size;
padding-left: 0;
padding-right: 0;
}
}

View File

@@ -0,0 +1,515 @@
/**
* External dependencies
*/
import { get, filter, map, pick, includes } from 'lodash';
/**
* WordPress dependencies
*/
import { isBlobURL } from '@wordpress/blob';
import {
ExternalLink,
PanelBody,
ResizableBox,
Spinner,
TextareaControl,
TextControl,
ToolbarButton,
} from '@wordpress/components';
import { useViewportMatch, usePrevious } from '@wordpress/compose';
import { useSelect, useDispatch } from '@wordpress/data';
import {
BlockControls,
InspectorControls,
RichText,
__experimentalImageSizeControl as ImageSizeControl,
__experimentalImageURLInputUI as ImageURLInputUI,
MediaReplaceFlow,
store as blockEditorStore,
BlockAlignmentControl,
__experimentalImageEditor as ImageEditor,
__experimentalImageEditingProvider as ImageEditingProvider,
} from '@wordpress/block-editor';
import { useEffect, useMemo, useState, useRef } from '@wordpress/element';
import { __, sprintf, isRTL } from '@wordpress/i18n';
import { getFilename } from '@wordpress/url';
import { createBlock, switchToBlockType } from '@wordpress/blocks';
import { crop, overlayText, upload } from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { createUpgradedEmbedBlock } from '../embed/util';
import useClientWidth from './use-client-width';
import { isExternalImage, isMediaDestroyed } from './edit';
/**
* Module constants
*/
import { MIN_SIZE, ALLOWED_MEDIA_TYPES } from './constants';
export default function Image( {
temporaryURL,
attributes: {
url = '',
alt,
caption,
align,
id,
href,
rel,
linkClass,
linkDestination,
title,
width,
height,
linkTarget,
sizeSlug,
media_fit,
media_align,
media_height,
media_width
},
setAttributes,
isSelected,
insertBlocksAfter,
onReplace,
onCloseModal,
onSelectImage,
onSelectURL,
onUploadError,
containerRef,
context,
clientId,
onImageLoadError,
} ) {
const imageRef = useRef();
const captionRef = useRef();
const prevUrl = usePrevious( url );
const { allowResize = true } = context;
const { getBlock } = useSelect( blockEditorStore );
const { image, multiImageSelection } = useSelect(
( select ) => {
const { getMedia } = select( coreStore );
const { getMultiSelectedBlockClientIds, getBlockName } = select(
blockEditorStore
);
const multiSelectedClientIds = getMultiSelectedBlockClientIds();
return {
image:
id && isSelected
? getMedia( id, { context: 'view' } )
: null,
multiImageSelection:
multiSelectedClientIds.length &&
multiSelectedClientIds.every(
( _clientId ) =>
getBlockName( _clientId ) === 'core/image'
),
};
},
[ id, isSelected ]
);
const {
canInsertCover,
imageEditing,
imageSizes,
maxWidth,
mediaUpload,
} = useSelect(
( select ) => {
const {
getBlockRootClientId,
getSettings,
canInsertBlockType,
} = select( blockEditorStore );
const rootClientId = getBlockRootClientId( clientId );
const settings = pick( getSettings(), [
'imageEditing',
'imageSizes',
'maxWidth',
'mediaUpload',
] );
return {
...settings,
canInsertCover: canInsertBlockType(
'core/cover',
rootClientId
),
};
},
[ clientId ]
);
const { replaceBlocks, toggleSelection } = useDispatch( blockEditorStore );
const { createErrorNotice, createSuccessNotice } = useDispatch(
noticesStore
);
const isLargeViewport = useViewportMatch( 'medium' );
const isWideAligned = includes( [ 'wide', 'full' ], align );
const [
{ loadedNaturalWidth, loadedNaturalHeight },
setLoadedNaturalSize,
] = useState( {} );
const [ isEditingImage, setIsEditingImage ] = useState( false );
const [ externalBlob, setExternalBlob ] = useState();
const clientWidth = useClientWidth( containerRef, [ align ] );
const isResizable = allowResize && ! ( isWideAligned && isLargeViewport );
const imageSizeOptions = map(
filter( imageSizes, ( { slug } ) =>
get( image, [ 'media_details', 'sizes', slug, 'source_url' ] )
),
( { name, slug } ) => ( { value: slug, label: name } )
);
// If an image is externally hosted, try to fetch the image data. This may
// fail if the image host doesn't allow CORS with the domain. If it works,
// we can enable a button in the toolbar to upload the image.
useEffect( () => {
if ( ! isExternalImage( id, url ) || ! isSelected || externalBlob ) {
return;
}
window
.fetch( url )
.then( ( response ) => response.blob() )
.then( ( blob ) => setExternalBlob( blob ) )
// Do nothing, cannot upload.
.catch( () => {} );
}, [ id, url, isSelected, externalBlob ] );
// Focus the caption after inserting an image from the placeholder. This is
// done to preserve the behaviour of focussing the first tabbable element
// when a block is mounted. Previously, the image block would remount when
// the placeholder is removed. Maybe this behaviour could be removed.
useEffect( () => {
if ( url && ! prevUrl && isSelected ) {
captionRef.current.focus();
}
}, [ url, prevUrl ] );
// Get naturalWidth and naturalHeight from image ref, and fall back to loaded natural
// width and height. This resolves an issue in Safari where the loaded natural
// witdth and height is otherwise lost when switching between alignments.
// See: https://github.com/WordPress/gutenberg/pull/37210.
const { naturalWidth, naturalHeight } = useMemo( () => {
return {
naturalWidth:
imageRef.current?.naturalWidth ||
loadedNaturalWidth ||
undefined,
naturalHeight:
imageRef.current?.naturalHeight ||
loadedNaturalHeight ||
undefined,
};
}, [
loadedNaturalWidth,
loadedNaturalHeight,
imageRef.current?.complete,
] );
function onResizeStart() {
toggleSelection( false );
}
function onResizeStop() {
toggleSelection( true );
}
function onImageError() {
// Check if there's an embed block that handles this URL, e.g., instagram URL.
// See: https://github.com/WordPress/gutenberg/pull/11472
const embedBlock = createUpgradedEmbedBlock( { attributes: { url } } );
const shouldReplace = undefined !== embedBlock;
if ( shouldReplace ) {
onReplace( embedBlock );
}
onImageLoadError( shouldReplace );
}
function onSetHref( props ) {
setAttributes( props );
}
function onSetTitle( value ) {
// This is the HTML title attribute, separate from the media object
// title.
setAttributes( { title: value } );
}
function updateAlt( newAlt ) {
setAttributes( { alt: newAlt } );
}
function updateImage( newSizeSlug ) {
const newUrl = get( image, [
'media_details',
'sizes',
newSizeSlug,
'source_url',
] );
if ( ! newUrl ) {
return null;
}
setAttributes( {
url: newUrl,
width: undefined,
height: undefined,
sizeSlug: newSizeSlug,
} );
}
function uploadExternal() {
mediaUpload( {
filesList: [ externalBlob ],
onFileChange( [ img ] ) {
onSelectImage( img );
if ( isBlobURL( img.url ) ) {
return;
}
setExternalBlob();
createSuccessNotice( __( 'Image uploaded.' ), {
type: 'snackbar',
} );
},
allowedTypes: ALLOWED_MEDIA_TYPES,
onError( message ) {
createErrorNotice( message, { type: 'snackbar' } );
},
} );
}
function updateAlignment( nextAlign ) {
const extraUpdatedAttributes = [ 'wide', 'full' ].includes( nextAlign )
? { width: undefined, height: undefined }
: {};
setAttributes( {
...extraUpdatedAttributes,
align: nextAlign,
} );
}
useEffect( () => {
if ( ! isSelected ) {
setIsEditingImage( false );
}
if ( isSelected && isMediaDestroyed( id ) ) {
onImageLoadError();
}
}, [ isSelected ] );
const canEditImage = id && naturalWidth && naturalHeight && imageEditing;
const allowCrop = ! multiImageSelection && canEditImage && ! isEditingImage;
function switchToCover() {
replaceBlocks(
clientId,
switchToBlockType( getBlock( clientId ), 'core/cover' )
);
}
const controls = (
<>
<BlockControls group="block">
<BlockAlignmentControl
value={ align }
onChange={ updateAlignment }
/>
{ ! multiImageSelection && ! isEditingImage && (
<ImageURLInputUI
url={ href || '' }
onChangeUrl={ onSetHref }
linkDestination={ linkDestination }
mediaUrl={ ( image && image.source_url ) || url }
mediaLink={ image && image.link }
linkTarget={ linkTarget }
linkClass={ linkClass }
rel={ rel }
/>
) }
{ allowCrop && (
<ToolbarButton
onClick={ () => setIsEditingImage( true ) }
icon={ crop }
label={ __( 'Crop' ) }
/>
) }
{ externalBlob && (
<ToolbarButton
onClick={ uploadExternal }
icon={ upload }
label={ __( 'Upload external image' ) }
/>
) }
{ ! multiImageSelection && canInsertCover && (
<ToolbarButton
icon={ overlayText }
label={ __( 'Add text over image' ) }
onClick={ switchToCover }
/>
) }
</BlockControls>
{ ! multiImageSelection && ! isEditingImage && (
<BlockControls group="other">
<MediaReplaceFlow
mediaId={ id }
mediaURL={ url }
allowedTypes={ ALLOWED_MEDIA_TYPES }
accept="image/*"
onSelect={ onSelectImage }
onSelectURL={ onSelectURL }
onError={ onUploadError }
onCloseModal={ onCloseModal }
/>
</BlockControls>
) }
<InspectorControls>
<PanelBody title={ __( 'Image settings' ) }>
{ ! multiImageSelection && (
<TextareaControl
label={ __( 'Alt text (alternative text)' ) }
value={ alt }
onChange={ updateAlt }
help={
<>
<ExternalLink href="https://www.w3.org/WAI/tutorials/images/decision-tree">
{ __(
'Describe the purpose of the image'
) }
</ExternalLink>
{ __(
'Leave empty if the image is purely decorative.'
) }
</>
}
/>
) }
<ImageSizeControl
onChangeImage={ updateImage }
onChange={ ( value ) => setAttributes( value ) }
slug={ sizeSlug }
width={ width }
height={ height }
imageSizeOptions={ imageSizeOptions }
isResizable={ isResizable }
imageWidth={ naturalWidth }
imageHeight={ naturalHeight }
/>
</PanelBody>
</InspectorControls>
<InspectorControls __experimentalGroup="advanced">
<TextControl
label={ __( 'Title attribute' ) }
value={ title || '' }
onChange={ onSetTitle }
help={
<>
{ __(
'Describe the role of this image on the page.'
) }
<ExternalLink href="https://www.w3.org/TR/html52/dom.html#the-title-attribute">
{ __(
'(Note: many devices and browsers do not display this text.)'
) }
</ExternalLink>
</>
}
/>
</InspectorControls>
</>
);
const filename = getFilename( url );
let defaultedAlt;
if ( alt ) {
defaultedAlt = alt;
} else if ( filename ) {
defaultedAlt = sprintf(
/* translators: %s: file name */
__( 'This image has an empty alt attribute; its file name is %s' ),
filename
);
} else {
defaultedAlt = __( 'This image has an empty alt attribute' );
}
var fit_class = media_fit ? media_fit : 'cover';
var align_class = media_align ? media_align : 'center';
var media_height = media_fit && media_fit == 'set' ? ( media_height ? media_height : '50' ) : false;
var media_width = media_fit && media_fit == 'set' ? ( media_width ? media_width : '100' ) : false;
var style = {};
if ( media_height ) {
style['max-height'] = media_height + 'px';
}
if ( media_width ) {
style['max-width'] = media_width + 'px';
}
let img = (
// Disable reason: Image itself is not meant to be interactive, but
// should direct focus to block.
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */
<>
<img
src={ temporaryURL || url }
alt={ defaultedAlt }
onError={ () => onImageError() }
onLoad={ ( event ) => {
setLoadedNaturalSize( {
loadedNaturalWidth: event.target?.naturalWidth,
loadedNaturalHeight: event.target?.naturalHeight,
} );
} }
ref={ imageRef }
style={ style }
/>
{ temporaryURL && <Spinner /> }
</>
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */
);
img = (
<div class="areoi-media position-relative">
<div class={ 'areoi-media-container ' + fit_class + ' ' + align_class }>
{ img }
</div>
</div>
);
return (
<>
{ /* Hide controls during upload to avoid component remount,
which causes duplicated image upload. */ }
{ ! temporaryURL && controls }
{ img }
{ ( ! RichText.isEmpty( caption ) || isSelected ) && (
<RichText
ref={ captionRef }
tagName="figcaption"
aria-label={ __( 'Image caption text' ) }
placeholder={ __( 'Add caption' ) }
value={ caption }
onChange={ ( value ) =>
setAttributes( { caption: value } )
}
inlineToolbar
__unstableOnSplitAtEnd={ () =>
insertBlocksAfter( createBlock( 'core/paragraph' ) )
}
/>
) }
</>
);
}

View File

@@ -0,0 +1,67 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { image as icon } from '@wordpress/icons';
import * as areoi from '../_components/Core.js';
/**
* Internal dependencies
*/
import deprecated from './deprecated';
import edit from './edit';
import metadata from './block.json';
import save from './save';
import transforms from './transforms';
const { name } = metadata;
export { metadata, name };
export const settings = {
icon,
example: {
attributes: {
sizeSlug: 'large',
url: 'https://s.w.org/images/core/5.3/MtBlanc1.jpg',
// translators: Caption accompanying an image of the Mont Blanc, which serves as an example for the Image block.
caption: __( 'Mont Blanc appears—still, snowy, and serene.' ),
},
},
__experimentalLabel( attributes, { context } ) {
if ( context === 'accessibility' ) {
const { caption, alt, url } = attributes;
if ( ! url ) {
return __( 'Empty' );
}
if ( ! alt ) {
return caption || '';
}
// This is intended to be read by a screen reader.
// A period simply means a pause, no need to translate it.
return alt + ( caption ? '. ' + caption : '' );
}
},
getEditWrapperProps( attributes ) {
return {
'data-align': attributes.align,
};
},
transforms,
edit,
save,
deprecated,
};
areoi.blocks.registerBlockType( metadata, {
icon: icon,
edit: edit,
save: () => {
return (
<areoi.editor.InnerBlocks.Content/>
);
},
});

View File

@@ -0,0 +1,42 @@
<?php
/**
* Server-side rendering of the `core/image` block.
*
* @package WordPress
*/
/**
* Renders the `core/image` block on the server,
* adding a data-id attribute to the element if core/gallery has added on pre-render.
*
* @param array $attributes The block attributes.
* @param string $content The block content.
* @return string Returns the block content with the data-id attribute added.
*/
function render_block_core_image( $attributes, $content ) {
if ( isset( $attributes['data-id'] ) ) {
// Add the data-id="$id" attribute to the img element
// to provide backwards compatibility for the Gallery Block,
// which now wraps Image Blocks within innerBlocks.
// The data-id attribute is added in a core/gallery `render_block_data` hook.
$data_id_attribute = 'data-id="' . esc_attr( $attributes['data-id'] ) . '"';
if ( false === strpos( $content, $data_id_attribute ) ) {
$content = str_replace( '<img', '<img ' . $data_id_attribute . ' ', $content );
}
}
return $content;
}
/**
* Registers the `core/image` block on server.
*/
function register_block_core_image() {
register_block_type_from_metadata(
__DIR__ . '/image',
array(
'render_callback' => 'render_block_core_image',
)
);
}
add_action( 'init', 'register_block_core_image' );

View File

@@ -0,0 +1,77 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { isEmpty } from 'lodash';
/**
* WordPress dependencies
*/
import { RichText, useBlockProps } from '@wordpress/block-editor';
import * as areoi from '../_components/Core.js';
export default function save( { attributes } ) {
const {
url,
alt,
caption,
align,
href,
rel,
linkClass,
width,
height,
id,
linkTarget,
sizeSlug,
title,
} = attributes;
const newRel = isEmpty( rel ) ? undefined : rel;
const className = 'areoi-content-grid-item';
const image = (
<img
src={ url }
alt={ alt }
className={ id ? `wp-image-${ id }` : null }
width={ width }
height={ height }
title={ title }
/>
);
const figure = (
<>
{ href ? (
<a
className={ linkClass }
href={ href }
target={ linkTarget }
rel={ newRel }
>
{ image }
</a>
) : (
<div>{image}</div>
) }
{ ! RichText.isEmpty( caption ) && (
<RichText.Content tagName="figcaption" value={ caption } />
) }
</>
);
// return (
// <figure { ...useBlockProps.save( { className: className } ) }>
// { figure }
// </figure>
// );
return (
<areoi.editor.InnerBlocks.Content/>
);
}

View File

@@ -0,0 +1,106 @@
.wp-block-image {
margin: 0 0 1em 0;
img {
height: auto;
max-width: 100%;
vertical-align: bottom;
}
&:not(.is-style-rounded) {
> a,
img {
border-radius: inherit;
}
}
&.aligncenter {
text-align: center;
}
&.alignfull img,
&.alignwide img {
height: auto;
width: 100%;
}
&.alignleft,
&.alignright,
&.aligncenter,
.alignleft,
.alignright,
.aligncenter {
display: table;
> figcaption {
display: table-caption;
caption-side: bottom;
}
}
.alignleft {
/*rtl:ignore*/
float: left;
/*rtl:ignore*/
margin-left: 0;
margin-right: 1em;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.alignright {
/*rtl:ignore*/
float: right;
/*rtl:ignore*/
margin-right: 0;
margin-left: 1em;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
// This is needed for classic themes where the align class is not on the container.
.aligncenter {
margin-left: auto;
margin-right: auto;
}
// Supply caption styles to images, even if the theme hasn't opted in.
// Reason being: the new markup, <figcaptions>, are not likely to be styled in the majority of existing themes,
// so we supply the styles so as to not appear broken or unstyled in those themes.
figcaption {
@include caption-style();
}
// Variations
&.is-style-rounded img,
.is-style-rounded img {
// We use an absolute pixel to prevent the oval shape that a value of 50% would give
// to rectangular images. A pill-shape is better than otherwise.
border-radius: 9999px;
}
// The following variation is deprecated.
// The CSS is kept here for the time being, to support blocks using the old variation.
&.is-style-circle-mask img {
// We use an absolute pixel to prevent the oval shape that a value of 50% would give
// to rectangular images. A pill-shape is better than otherwise.
border-radius: 9999px;
// If a browser supports it, we will switch to using a circular SVG mask.
// The stylelint override is necessary to use the SVG inline here.
@supports (mask-image: none) or (-webkit-mask-image: none) {
/* stylelint-disable */
mask-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50"/></svg>');
/* stylelint-enable */
mask-mode: alpha;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
border-radius: 0;
}
}
}
.wp-block-image figure {
margin: 0;
}

View File

@@ -0,0 +1,59 @@
// @format
.content {
flex: 1;
}
.contentCentered {
flex: 1;
justify-content: center;
align-items: center;
}
.iconPlaceholder {
fill: $gray-dark;
width: 100%;
height: 100%;
}
.iconPlaceholderDark {
fill: $white;
}
.is-style-rounded {
border-radius: 9999px;
overflow: hidden;
}
.panelBody {
padding-left: 0;
padding-right: 0;
padding-bottom: $grid-unit;
}
.fixedHeight {
height: 150;
overflow: visible;
}
.featuredImagePanelTitle {
padding-bottom: 0;
}
.setFeaturedButtonCellContainer {
align-items: flex-start;
}
.setFeaturedButton {
text-align: left;
color: $blue-50;
padding: $grid-unit-15 0;
}
.setFeaturedButtonDark {
color: $blue-30;
}
.removeFeaturedButton {
color: $alert-red;
}

View File

@@ -0,0 +1,409 @@
/**
* External dependencies
*/
import {
act,
fireEvent,
initializeEditor,
getEditorHtml,
render,
} from 'test/helpers';
import { Image } from 'react-native';
import Clipboard from '@react-native-clipboard/clipboard';
/**
* WordPress dependencies
*/
import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks';
import {
setFeaturedImage,
sendMediaUpload,
subscribeMediaUpload,
} from '@wordpress/react-native-bridge';
import { select } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { store as coreStore } from '@wordpress/core-data';
import '@wordpress/jest-console';
/**
* Internal dependencies
*/
import { registerCoreBlocks } from '../..';
import ImageEdit from '../edit';
let uploadCallBack;
subscribeMediaUpload.mockImplementation( ( callback ) => {
uploadCallBack = callback;
} );
sendMediaUpload.mockImplementation( ( payload ) => {
uploadCallBack( payload );
} );
/**
* Immediately invoke delayed functions. A better alternative would be using
* fake timers and test the delay itself. However, fake timers does not work
* with our custom waitFor implementation.
*/
jest.mock( 'lodash', () => {
const actual = jest.requireActual( 'lodash' );
return { ...actual, delay: ( cb ) => cb() };
} );
const apiFetchPromise = Promise.resolve( {} );
const clipboardPromise = Promise.resolve( '' );
Clipboard.getString.mockImplementation( () => clipboardPromise );
beforeAll( () => {
registerCoreBlocks();
// Mock Image.getSize to avoid failed attempt to size non-existant image
const getSizeSpy = jest.spyOn( Image, 'getSize' );
getSizeSpy.mockImplementation( ( _url, callback ) => callback( 300, 200 ) );
} );
afterAll( () => {
getBlockTypes().forEach( ( { name } ) => {
unregisterBlockType( name );
} );
// Restore mocks.
Image.getSize.mockRestore();
} );
describe( 'Image Block', () => {
it( 'sets link to None', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"media","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<a href="https://cldup.com/cXyG__fTLN.jpg">
<img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/>
</a>
<figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
fireEvent.press( screen.getByA11yLabel( /Image Block/ ) );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () =>
fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) )
);
fireEvent.press( screen.getByText( 'Media File' ) );
fireEvent.press( screen.getByText( 'None' ) );
const expectedHtml = `<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"none","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/><figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
expect( getEditorHtml() ).toBe( expectedHtml );
} );
it( 'sets link to Media File', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"none","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/>
<figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
fireEvent.press( screen.getByA11yLabel( /Image Block/ ) );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () =>
fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) )
);
fireEvent.press( screen.getByText( 'None' ) );
fireEvent.press( screen.getByText( 'Media File' ) );
const expectedHtml = `<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"media","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><a href="https://cldup.com/cXyG__fTLN.jpg"><img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/></a><figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
expect( getEditorHtml() ).toBe( expectedHtml );
} );
it( 'sets link to Custom URL', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"none","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/>
<figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
fireEvent.press( screen.getByA11yLabel( /Image Block/ ) );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () =>
fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) )
);
fireEvent.press( screen.getByText( 'None' ) );
fireEvent.press( screen.getByText( 'Custom URL' ) );
// Await asynchronous fetch of clipboard
await act( () => clipboardPromise );
fireEvent.changeText(
screen.getByPlaceholderText( 'Search or type URL' ),
'wordpress.org'
);
fireEvent.press( screen.getByA11yLabel( 'Apply' ) );
const expectedHtml = `<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"custom","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><a href="http://wordpress.org"><img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/></a><figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
expect( getEditorHtml() ).toBe( expectedHtml );
} );
it( 'swaps the link between destinations', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"none","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/>
<figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
fireEvent.press( screen.getByA11yLabel( /Image Block/ ) );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () =>
fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) )
);
fireEvent.press( screen.getByText( 'None' ) );
fireEvent.press( screen.getByText( 'Media File' ) );
fireEvent.press( screen.getByText( 'Custom URL' ) );
// Await asynchronous fetch of clipboard
await act( () => clipboardPromise );
fireEvent.changeText(
screen.getByPlaceholderText( 'Search or type URL' ),
'wordpress.org'
);
fireEvent.press( screen.getByA11yLabel( 'Apply' ) );
fireEvent.press( screen.getByText( 'Custom URL' ) );
// Await asynchronous fetch of clipboard
await act( () => clipboardPromise );
fireEvent.press( screen.getByText( 'Media File' ) );
const expectedHtml = `<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"media","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><a href="https://cldup.com/cXyG__fTLN.jpg"><img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/></a><figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
expect( getEditorHtml() ).toBe( expectedHtml );
} );
it( 'does not display the Link To URL within the Custom URL input when set to Media File and query parameters are present', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"media","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<a href="https://cldup.com/cXyG__fTLN.jpg">
<img src="https://cldup.com/cXyG__fTLN.jpg?w=683" alt="" class="wp-image-1"/>
</a>
<figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
fireEvent.press( screen.getByA11yLabel( /Image Block/ ) );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () =>
fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) )
);
fireEvent.press( screen.getByText( 'Media File' ) );
expect( screen.queryByA11yLabel( /https:\/\/cldup\.com/ ) ).toBeNull();
} );
it( 'sets link target', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"custom","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<a href="https://wordpress.org">
<img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/>
</a>
<figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
const imageBlock = screen.getByA11yLabel( /Image Block/ );
fireEvent.press( imageBlock );
const settingsButton = screen.getByA11yLabel( 'Open Settings' );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () => fireEvent.press( settingsButton ) );
const linkTargetButton = screen.getByText( 'Open in new tab' );
fireEvent.press( linkTargetButton );
const expectedHtml = `<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"custom","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><a href="https://wordpress.org" target="_blank" rel="noreferrer noopener"><img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/></a><figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
expect( getEditorHtml() ).toBe( expectedHtml );
} );
it( 'unset link target', async () => {
const initialHtml = `
<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"custom","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default">
<a href="https://wordpress.org" target="_blank" rel="noreferrer noopener">
<img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/>
</a>
<figcaption>Mountain</figcaption>
</figure>
<!-- /wp:image -->`;
const screen = await initializeEditor( { initialHtml } );
// We must await the image fetch via `getMedia`
await act( () => apiFetchPromise );
const imageBlock = screen.getByA11yLabel( /Image Block/ );
fireEvent.press( imageBlock );
const settingsButton = screen.getByA11yLabel( 'Open Settings' );
// Awaiting navigation event seemingly required due to React Navigation bug
// https://git.io/Ju35Z
await act( () => fireEvent.press( settingsButton ) );
const linkTargetButton = screen.getByText( 'Open in new tab' );
fireEvent.press( linkTargetButton );
const expectedHtml = `<!-- wp:image {"id":1,"sizeSlug":"large","linkDestination":"custom","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><a href="https://wordpress.org"><img src="https://cldup.com/cXyG__fTLN.jpg" alt="" class="wp-image-1"/></a><figcaption>Mountain</figcaption></figure>
<!-- /wp:image -->`;
expect( getEditorHtml() ).toBe( expectedHtml );
} );
describe( "when replacing media for an image set as the post's featured image", () => {
function mockFeaturedMedia( featuredImageId ) {
jest.spyOn(
select( editorStore ),
'getEditedPostAttribute'
).mockImplementation( ( attributeName ) =>
attributeName === 'featured_media' ? featuredImageId : undefined
);
}
function mockGetMedia( media ) {
jest.spyOn( select( coreStore ), 'getMedia' ).mockReturnValueOnce(
media
);
}
it( 'does not prompt to replace featured image during a new image upload', () => {
// Arrange
const INITIAL_IMAGE = { id: 1, url: 'mock-url-1' };
const NEW_IMAGE_PENDING = { id: 2, url: 'mock-url-2' };
mockFeaturedMedia( INITIAL_IMAGE.id );
const screen = render( <ImageEdit attributes={ INITIAL_IMAGE } /> );
// Act
screen.update( <ImageEdit attributes={ NEW_IMAGE_PENDING } /> );
const MEDIA_UPLOAD_STATE_UPLOADING = 1;
sendMediaUpload( {
state: MEDIA_UPLOAD_STATE_UPLOADING,
mediaId: NEW_IMAGE_PENDING.id,
progress: 0.1,
} );
// Assert
expect( setFeaturedImage ).not.toHaveBeenCalled();
} );
it( 'does not prompt to replace featured image after a new image upload fails', () => {
// Arrange
const INITIAL_IMAGE = { id: 1, url: 'mock-url-1' };
const NEW_IMAGE_PENDING = { id: 2, url: 'mock-url-2' };
mockFeaturedMedia( INITIAL_IMAGE.id );
const screen = render(
<ImageEdit
attributes={ INITIAL_IMAGE }
setAttributes={ () => {} }
/>
);
// Act
screen.update(
<ImageEdit
attributes={ NEW_IMAGE_PENDING }
setAttributes={ () => {} }
/>
);
const MEDIA_UPLOAD_STATE_UPLOADING = 1;
sendMediaUpload( {
state: MEDIA_UPLOAD_STATE_UPLOADING,
mediaId: NEW_IMAGE_PENDING.id,
progress: 0.1,
} );
const MEDIA_UPLOAD_STATE_FAILED = 3;
sendMediaUpload( {
state: MEDIA_UPLOAD_STATE_FAILED,
mediaId: NEW_IMAGE_PENDING.id,
} );
// Assert
expect( setFeaturedImage ).not.toHaveBeenCalled();
} );
it( 'prompts to replace featured image after a new image upload succeeds', () => {
// Arrange
const INITIAL_IMAGE = { id: 1, url: 'mock-url-1' };
const NEW_IMAGE_PENDING = { id: 2, url: 'mock-url-2' };
const NEW_IMAGE_RESOLVED = { id: 3, url: 'mock-url-2' };
mockFeaturedMedia( INITIAL_IMAGE.id );
const screen = render( <ImageEdit attributes={ INITIAL_IMAGE } /> );
// Act
screen.update( <ImageEdit attributes={ NEW_IMAGE_PENDING } /> );
mockGetMedia( { id: NEW_IMAGE_RESOLVED.id } );
screen.update(
<ImageEdit
attributes={ NEW_IMAGE_RESOLVED }
setAttributes={ () => {} }
/>
);
// Assert
expect( setFeaturedImage ).toHaveBeenCalledTimes( 1 );
expect( setFeaturedImage ).toHaveBeenCalledWith(
NEW_IMAGE_RESOLVED.id
);
} );
it( 'prompts to replace featured image for a cached image', () => {
// Arrange
const INITIAL_IMAGE = { id: 1, url: 'mock-url-1' };
const NEW_IMAGE_RESOLVED = { id: 3, url: 'mock-url-2' };
mockFeaturedMedia( INITIAL_IMAGE.id );
const screen = render(
<ImageEdit
attributes={ INITIAL_IMAGE }
setAttributes={ () => {} }
/>
);
// Act
mockGetMedia( { id: NEW_IMAGE_RESOLVED.id } );
screen.update(
<ImageEdit
attributes={ NEW_IMAGE_RESOLVED }
setAttributes={ () => {} }
/>
);
// Assert
expect( setFeaturedImage ).toHaveBeenCalledTimes( 1 );
expect( setFeaturedImage ).toHaveBeenCalledWith(
NEW_IMAGE_RESOLVED.id
);
} );
} );
} );

View File

@@ -0,0 +1,66 @@
/**
* Internal dependencies
*/
import { stripFirstImage } from '../transforms';
describe( 'stripFirstImage', () => {
test( 'should do nothing if no image is present', () => {
expect( stripFirstImage( {}, { shortcode: { content: '' } } ) ).toEqual(
''
);
expect(
stripFirstImage( {}, { shortcode: { content: 'Tucson' } } )
).toEqual( 'Tucson' );
expect(
stripFirstImage( {}, { shortcode: { content: '<em>Tucson</em>' } } )
).toEqual( '<em>Tucson</em>' );
} );
test( 'should strip out image when leading as expected', () => {
expect(
stripFirstImage( {}, { shortcode: { content: '<img>' } } )
).toEqual( '' );
expect(
stripFirstImage( {}, { shortcode: { content: '<img>Image!' } } )
).toEqual( 'Image!' );
expect(
stripFirstImage(
{},
{ shortcode: { content: '<img src="image.png">Image!' } }
)
).toEqual( 'Image!' );
} );
test( 'should strip out image when not in leading position as expected', () => {
expect(
stripFirstImage( {}, { shortcode: { content: 'Before<img>' } } )
).toEqual( 'Before' );
expect(
stripFirstImage(
{},
{ shortcode: { content: 'Before<img>Image!' } }
)
).toEqual( 'BeforeImage!' );
expect(
stripFirstImage(
{},
{ shortcode: { content: 'Before<img src="image.png">Image!' } }
)
).toEqual( 'BeforeImage!' );
} );
test( 'should strip out only the first of many images', () => {
expect(
stripFirstImage( {}, { shortcode: { content: '<img><img>' } } )
).toEqual( '<img>' );
} );
test( 'should strip out the first image and its wrapping parents', () => {
expect(
stripFirstImage(
{},
{ shortcode: { content: '<p><a><img></a></p><p><img></p>' } }
)
).toEqual( '<p><img></p>' );
} );
} );

View File

@@ -0,0 +1,3 @@
.wp-block-image figcaption {
@include caption-style-theme();
}

View File

@@ -0,0 +1,234 @@
/**
* External dependencies
*/
import { every } from 'lodash';
/**
* WordPress dependencies
*/
import { createBlobURL } from '@wordpress/blob';
import { createBlock, getBlockAttributes } from '@wordpress/blocks';
import { dispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { __ } from '@wordpress/i18n';
export function stripFirstImage( attributes, { shortcode } ) {
const { body } = document.implementation.createHTMLDocument( '' );
body.innerHTML = shortcode.content;
let nodeToRemove = body.querySelector( 'img' );
// If an image has parents, find the topmost node to remove.
while (
nodeToRemove &&
nodeToRemove.parentNode &&
nodeToRemove.parentNode !== body
) {
nodeToRemove = nodeToRemove.parentNode;
}
if ( nodeToRemove ) {
nodeToRemove.parentNode.removeChild( nodeToRemove );
}
return body.innerHTML.trim();
}
function getFirstAnchorAttributeFormHTML( html, attributeName ) {
const { body } = document.implementation.createHTMLDocument( '' );
body.innerHTML = html;
const { firstElementChild } = body;
if ( firstElementChild && firstElementChild.nodeName === 'A' ) {
return firstElementChild.getAttribute( attributeName ) || undefined;
}
}
const imageSchema = {
img: {
attributes: [ 'src', 'alt', 'title' ],
classes: [
'alignleft',
'aligncenter',
'alignright',
'alignnone',
/^wp-image-\d+$/,
],
},
};
const schema = ( { phrasingContentSchema } ) => ( {
figure: {
require: [ 'img' ],
children: {
...imageSchema,
a: {
attributes: [ 'href', 'rel', 'target' ],
children: imageSchema,
},
figcaption: {
children: phrasingContentSchema,
},
},
},
} );
const transforms = {
from: [
{
type: 'raw',
isMatch: ( node ) =>
node.nodeName === 'FIGURE' && !! node.querySelector( 'img' ),
schema,
transform: ( node ) => {
// Search both figure and image classes. Alignment could be
// set on either. ID is set on the image.
const className =
node.className +
' ' +
node.querySelector( 'img' ).className;
const alignMatches = /(?:^|\s)align(left|center|right)(?:$|\s)/.exec(
className
);
const anchor = node.id === '' ? undefined : node.id;
const align = alignMatches ? alignMatches[ 1 ] : undefined;
const idMatches = /(?:^|\s)wp-image-(\d+)(?:$|\s)/.exec(
className
);
const id = idMatches ? Number( idMatches[ 1 ] ) : undefined;
const anchorElement = node.querySelector( 'a' );
const linkDestination =
anchorElement && anchorElement.href ? 'custom' : undefined;
const href =
anchorElement && anchorElement.href
? anchorElement.href
: undefined;
const rel =
anchorElement && anchorElement.rel
? anchorElement.rel
: undefined;
const linkClass =
anchorElement && anchorElement.className
? anchorElement.className
: undefined;
const attributes = getBlockAttributes(
'core/image',
node.outerHTML,
{
align,
id,
linkDestination,
href,
rel,
linkClass,
anchor,
}
);
return createBlock( 'core/image', attributes );
},
},
{
// Note: when dragging and dropping multiple files onto a gallery this overrides the
// gallery transform in order to add new images to the gallery instead of
// creating a new gallery.
type: 'files',
isMatch( files ) {
// The following check is intended to catch non-image files when dropped together with images.
if (
files.some(
( file ) => file.type.indexOf( 'image/' ) === 0
) &&
files.some(
( file ) => file.type.indexOf( 'image/' ) !== 0
)
) {
const { createErrorNotice } = dispatch( noticesStore );
createErrorNotice(
__(
'If uploading to a gallery all files need to be image formats'
),
{ id: 'gallery-transform-invalid-file' }
);
}
return every(
files,
( file ) => file.type.indexOf( 'image/' ) === 0
);
},
transform( files ) {
const blocks = files.map( ( file ) => {
return createBlock( 'core/image', {
url: createBlobURL( file ),
} );
} );
return blocks;
},
},
{
type: 'shortcode',
tag: 'caption',
attributes: {
url: {
type: 'string',
source: 'attribute',
attribute: 'src',
selector: 'img',
},
alt: {
type: 'string',
source: 'attribute',
attribute: 'alt',
selector: 'img',
},
caption: {
shortcode: stripFirstImage,
},
href: {
shortcode: ( attributes, { shortcode } ) => {
return getFirstAnchorAttributeFormHTML(
shortcode.content,
'href'
);
},
},
rel: {
shortcode: ( attributes, { shortcode } ) => {
return getFirstAnchorAttributeFormHTML(
shortcode.content,
'rel'
);
},
},
linkClass: {
shortcode: ( attributes, { shortcode } ) => {
return getFirstAnchorAttributeFormHTML(
shortcode.content,
'class'
);
},
},
id: {
type: 'number',
shortcode: ( { named: { id } } ) => {
if ( ! id ) {
return;
}
return parseInt( id.replace( 'attachment_', '' ), 10 );
},
},
align: {
type: 'string',
shortcode: ( { named: { align = 'alignnone' } } ) => {
return align.replace( 'align', '' );
},
},
},
},
],
};
export default transforms;

View File

@@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import webTransforms from './transforms.js';
import transformationCategories from '../transformationCategories';
const transforms = {
...webTransforms,
supportedMobileTransforms: transformationCategories.media,
};
export default transforms;

View File

@@ -0,0 +1,25 @@
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
export default function useClientWidth( ref, dependencies ) {
const [ clientWidth, setClientWidth ] = useState();
function calculateClientWidth() {
setClientWidth( ref.current.clientWidth );
}
useEffect( calculateClientWidth, dependencies );
useEffect( () => {
const { defaultView } = ref.current.ownerDocument;
defaultView.addEventListener( 'resize', calculateClientWidth );
return () => {
defaultView.removeEventListener( 'resize', calculateClientWidth );
};
}, [] );
return clientWidth;
}

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { isEmpty, each, get } from 'lodash';
/**
* Internal dependencies
*/
import { NEW_TAB_REL } from './constants';
export function removeNewTabRel( currentRel ) {
let newRel = currentRel;
if ( currentRel !== undefined && ! isEmpty( newRel ) ) {
if ( ! isEmpty( newRel ) ) {
each( NEW_TAB_REL, ( relVal ) => {
const regExp = new RegExp( '\\b' + relVal + '\\b', 'gi' );
newRel = newRel.replace( regExp, '' );
} );
// Only trim if NEW_TAB_REL values was replaced.
if ( newRel !== currentRel ) {
newRel = newRel.trim();
}
if ( isEmpty( newRel ) ) {
newRel = undefined;
}
}
}
return newRel;
}
/**
* Helper to get the link target settings to be stored.
*
* @param {boolean} value The new link target value.
* @param {Object} attributes Block attributes.
* @param {Object} attributes.rel Image block's rel attribute.
*
* @return {Object} Updated link target settings.
*/
export function getUpdatedLinkTargetSettings( value, { rel } ) {
const linkTarget = value ? '_blank' : undefined;
let updatedRel;
if ( ! linkTarget && ! rel ) {
updatedRel = undefined;
} else {
updatedRel = removeNewTabRel( rel );
}
return {
linkTarget,
rel: updatedRel,
};
}
/**
* Determines new Image block attributes size selection.
*
* @param {Object} image Media file object for gallery image.
* @param {string} size Selected size slug to apply.
*/
export function getImageSizeAttributes( image, size ) {
const url = get( image, [ 'media_details', 'sizes', size, 'source_url' ] );
if ( url ) {
return { url, width: undefined, height: undefined, sizeSlug: size };
}
return {};
}