@wordpress/block-library
Version:
Block library for the WordPress editor.
862 lines (769 loc) • 25.8 kB
JavaScript
import _extends from "@babel/runtime/helpers/esm/extends";
import { createElement, Fragment } from "@wordpress/element";
/**
* External dependencies
*/
import { ActivityIndicator, Image as RNImage, TouchableWithoutFeedback, View } 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, isURL } 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';
import { store as noticesStore } from '@wordpress/notices';
/**
* 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) => {
var _image$media_details, _image$media_details$, _image$media_details$2;
if (!sizeSlug) {
return undefined;
}
return image === null || image === void 0 ? void 0 : (_image$media_details = image.media_details) === null || _image$media_details === void 0 ? void 0 : (_image$media_details$ = _image$media_details.sizes) === null || _image$media_details$ === void 0 ? void 0 : (_image$media_details$2 = _image$media_details$[sizeSlug]) === null || _image$media_details$2 === void 0 ? void 0 : _image$media_details$2.source_url;
};
function LinkSettings(_ref) {
var _route$params;
let {
attributes,
image,
isLinkSheetVisible,
setMappedAttributes
} = _ref;
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 === null || image === void 0 ? void 0 : 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 = route.params) === null || _route$params === void 0 ? void 0 : _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 createElement(PanelBody, {
title: __('Link Settings')
}, createElement(LinkSettingsNavigation, {
isVisible: isLinkSheetVisible,
url: url,
rel: rel,
label: label,
linkTarget: linkTarget,
setAttributes: setMappedAttributes,
withBottomSheet: false,
hasPicker: true,
options: linkSettingsOptions,
showIcon: false,
onLinkCellPressed: _ref2 => {
let {
navigation
} = _ref2;
navigation.navigate(blockSettingsScreens.imageLinkDestinations, {
inputValue: attributes.href,
linkDestination: attributes.linkDestination,
imageUrl: attributes.url,
attachmentPageUrl: image === null || image === void 0 ? void 0 : image.link
});
}
}));
}
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
};
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.onSelectURL = this.onSelectURL.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;
const {
url
} = attributes;
if (!previousProps.image && image) {
if (!hasQueryArg(url, 'w') && attributes !== null && attributes !== void 0 && attributes.sizeSlug) {
const updatedUrl = getUrlForSlug(image, attributes.sizeSlug) || image.source_url;
setAttributes({
url: updatedUrl
});
}
}
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
});
}
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,
alt: media.alt
};
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
});
}
onSelectURL(newURL) {
const {
createErrorNotice,
imageDefaultSize,
setAttributes
} = this.props;
if (isURL(newURL)) {
this.setState({
isFetchingImage: true
}); // Use RN's Image.getSize to determine if URL is a valid image
RNImage.getSize(newURL, () => {
setAttributes({
url: newURL,
id: undefined,
width: undefined,
height: undefined,
sizeSlug: imageDefaultSize
});
this.setState({
isFetchingImage: false
});
}, () => {
createErrorNotice(__('Image file not found.'));
this.setState({
isFetchingImage: false
});
});
} else {
createErrorNotice(__('Invalid URL.'));
}
}
onFocusCaption() {
if (this.props.onFocus) {
this.props.onFocus();
}
if (!this.state.isCaptionSelected) {
this.setState({
isCaptionSelected: true
});
}
}
getPlaceholderIcon() {
return createElement(Icon, _extends({
icon: placeholderIcon
}, this.props.getStylesFromColorScheme(styles.iconPlaceholder, styles.iconPlaceholderDark)));
}
showLoadingIndicator() {
return createElement(View, {
style: styles.image__loading
}, createElement(ActivityIndicator, {
animating: true
}));
}
getWidth() {
const {
attributes
} = this.props;
const {
align,
width
} = attributes;
return Object.values(WIDE_ALIGNMENTS.alignments).includes(align) ? '100%' : width;
}
setMappedAttributes(_ref3) {
let {
url: href,
linkDestination,
...restAttributes
} = _ref3;
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 createElement(BottomSheetTextControl, {
initialValue: alt,
onChange: updateAlt,
placeholder: __('Add alt text'),
label: __('Alt Text'),
icon: textColor,
footerNote: createElement(Fragment, null, __('Describe the purpose of the image. Leave empty if the image is purely decorative.'), ' ', createElement(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 = () => createElement(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 = () => createElement(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;
}
isGif(url) {
return url.toLowerCase().includes('.gif');
}
render() {
const {
isCaptionSelected,
isFetchingImage
} = this.state;
const {
attributes,
isSelected,
image,
clientId,
imageDefaultSize,
context,
featuredImageId,
wasBlockJustInserted,
shouldUseFastImage
} = 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(_ref4 => {
let {
slug
} = _ref4;
return getUrlForSlug(image, slug);
}).map(_ref5 => {
let {
name,
slug
} = _ref5;
return {
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 => createElement(BlockControls, null, createElement(ToolbarGroup, null, createElement(ToolbarButton, {
title: __('Edit image'),
icon: replace,
onClick: open
})), createElement(BlockAlignmentToolbar, {
value: align,
onChange: this.updateAlignment
}));
const getInspectorControls = () => createElement(InspectorControls, null, createElement(PanelBody, {
title: __('Settings')
}), createElement(PanelBody, {
style: styles.panelBody
}, createElement(BlockStyles, {
clientId: clientId,
url: url
})), createElement(PanelBody, null, image && sizeOptionsValid && createElement(BottomSheetSelectControl, {
icon: fullscreen,
label: __('Size'),
options: sizeOptions,
onChange: this.onSizeChangeValue,
value: selectedSizeOption
}), this.getAltTextSettings()), createElement(LinkSettings, {
attributes: this.props.attributes,
image: this.props.image,
isLinkSheetVisible: this.state.isLinkSheetVisible,
setMappedAttributes: this.setMappedAttributes
}), createElement(PanelBody, {
title: __('Featured Image'),
titleStyle: styles.featuredImagePanelTitle
}, canImageBeFeatured && this.getFeaturedButtonPanel(isFeaturedImage), createElement(FooterMessageControl, {
label: __('Changes to featured image will not be affected by the undo/redo buttons.'),
cellContainerStyle: styles.setFeaturedButtonCellContainer
})));
if (!url) {
return createElement(View, {
style: styles.content
}, isFetchingImage && this.showLoadingIndicator(), createElement(MediaPlaceholder, {
allowedTypes: [MEDIA_TYPE_IMAGE],
onSelect: this.onSelectMediaUploadOption,
onSelectURL: this.onSelectURL,
icon: this.getPlaceholderIcon(),
onFocus: this.props.onFocus,
autoOpenMediaUpload: isSelected && wasBlockJustInserted
}));
}
const alignToFlex = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
full: 'center',
wide: 'center'
};
const additionalImageProps = {
height: '100%',
resizeMode: context !== null && context !== void 0 && context.imageCrop ? 'cover' : 'contain'
};
const imageContainerStyles = [(context === null || context === void 0 ? void 0 : context.fixedHeight) && styles.fixedHeight];
const isGif = this.isGif(url);
const badgeLabelShown = isFeaturedImage || isGif;
let badgeLabelText = '';
if (isFeaturedImage) {
badgeLabelText = __('Featured');
} else if (isGif) {
badgeLabelText = __('GIF');
}
const getImageComponent = (openMediaOptions, getMediaOptions) => createElement(Badge, {
label: badgeLabelText,
show: badgeLabelShown
}, createElement(TouchableWithoutFeedback, {
accessible: !isSelected,
onPress: this.onImagePressed,
disabled: !isSelected
}, createElement(View, {
style: styles.content
}, isSelected && getInspectorControls(), isSelected && getMediaOptions(), !this.state.isCaptionSelected && getToolbarEditButton(openMediaOptions), createElement(MediaUploadProgress, {
coverUrl: url,
mediaId: id,
onUpdateMediaProgress: this.updateMediaProgress,
onFinishMediaUploadWithSuccess: this.finishMediaUploadWithSuccess,
onFinishMediaUploadWithFailure: this.finishMediaUploadWithFailure,
onMediaUploadStateReset: this.mediaUploadStateReset,
renderContent: _ref6 => {
let {
isUploadInProgress,
isUploadFailed,
retryMessage
} = _ref6;
return createElement(View, {
style: imageContainerStyles
}, isFetchingImage && this.showLoadingIndicator(), createElement(Image, _extends({
align: align && alignToFlex[align],
alt: alt,
isSelected: isSelected && !isCaptionSelected,
isUploadFailed: isUploadFailed,
isUploadInProgress: isUploadInProgress,
shouldUseFastImage: shouldUseFastImage,
onSelectMediaUploadOption: this.onSelectMediaUploadOption,
openMediaOptions: openMediaOptions,
retryMessage: retryMessage,
url: url,
shapeStyle: styles[className] || className,
width: this.getWidth()
}, hasImageContext ? additionalImageProps : {})));
}
}))), createElement(BlockCaption, {
clientId: this.props.clientId,
isSelected: this.state.isCaptionSelected,
accessible: true,
accessibilityLabelCreator: this.accessibilityLabelCreator,
onFocus: this.onFocusCaption,
onBlur: this.props.onBlur // Always assign onBlur as props.
,
insertBlocksAfter: this.props.insertBlocksAfter
}));
return createElement(MediaUpload, {
allowedTypes: [MEDIA_TYPE_IMAGE],
isReplacingMedia: true,
onSelect: this.onSelectMediaUploadOption,
onSelectURL: this.onSelectURL,
render: _ref7 => {
let {
open,
getMediaOptions
} = _ref7;
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,
shouldUseFastImage
} = 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');
const image = shouldGetMedia ? getMedia(id) : null;
return {
image,
imageSizes,
imageDefaultSize,
shouldUseFastImage,
featuredImageId,
wasBlockJustInserted: wasBlockJustInserted(clientId, 'inserter_menu')
};
}), withDispatch(dispatch => {
const {
createErrorNotice
} = dispatch(noticesStore);
return {
createErrorNotice,
closeSettingsBottomSheet() {
dispatch(editPostStore).closeGeneralSidebar();
}
};
}), withPreferredColorScheme])(ImageEdit);
//# sourceMappingURL=edit.native.js.map