@wordpress/block-library
Version:
Block library for the WordPress editor.
517 lines (475 loc) • 17.3 kB
JavaScript
import { createElement, Fragment } from "@wordpress/element";
/**
* External dependencies
*/
import { get, filter, isEmpty, 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, __experimentalGetElementClassName, __experimentalUseBorderProps as useBorderProps } 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, getDefaultBlockName, 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 } from './edit';
/**
* Module constants
*/
import { MIN_SIZE, ALLOWED_MEDIA_TYPES } from './constants';
export default function Image(_ref) {
var _imageRef$current3, _attributes$className;
let {
temporaryURL,
attributes,
setAttributes,
isSelected,
insertBlocksAfter,
onReplace,
onSelectImage,
onSelectURL,
onUploadError,
containerRef,
context,
clientId,
isContentLocked
} = _ref;
const {
url = '',
alt,
caption,
align,
id,
href,
rel,
linkClass,
linkDestination,
title,
width,
height,
linkTarget,
sizeSlug
} = attributes;
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, clientId]);
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 && !isContentLocked && !(isWideAligned && isLargeViewport);
const imageSizeOptions = map(filter(imageSizes, _ref2 => {
let {
slug
} = _ref2;
return get(image, ['media_details', 'sizes', slug, 'source_url']);
}), _ref3 => {
let {
name,
slug
} = _ref3;
return {
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
// width and height is otherwise lost when switching between alignments.
// See: https://github.com/WordPress/gutenberg/pull/37210.
const {
naturalWidth,
naturalHeight
} = useMemo(() => {
var _imageRef$current, _imageRef$current2;
return {
naturalWidth: ((_imageRef$current = imageRef.current) === null || _imageRef$current === void 0 ? void 0 : _imageRef$current.naturalWidth) || loadedNaturalWidth || undefined,
naturalHeight: ((_imageRef$current2 = imageRef.current) === null || _imageRef$current2 === void 0 ? void 0 : _imageRef$current2.naturalHeight) || loadedNaturalHeight || undefined
};
}, [loadedNaturalWidth, loadedNaturalHeight, (_imageRef$current3 = imageRef.current) === null || _imageRef$current3 === void 0 ? void 0 : _imageRef$current3.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
}
});
if (undefined !== embedBlock) {
onReplace(embedBlock);
}
}
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(_ref4) {
let [img] = _ref4;
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);
}
}, [isSelected]);
const canEditImage = id && naturalWidth && naturalHeight && imageEditing;
const allowCrop = !multiImageSelection && canEditImage && !isEditingImage;
function switchToCover() {
replaceBlocks(clientId, switchToBlockType(getBlock(clientId), 'core/cover'));
}
const controls = createElement(Fragment, null, createElement(BlockControls, {
group: "block"
}, !isContentLocked && createElement(BlockAlignmentControl, {
value: align,
onChange: updateAlignment
}), !multiImageSelection && !isEditingImage && createElement(ImageURLInputUI, {
url: href || '',
onChangeUrl: onSetHref,
linkDestination: linkDestination,
mediaUrl: image && image.source_url || url,
mediaLink: image && image.link,
linkTarget: linkTarget,
linkClass: linkClass,
rel: rel
}), allowCrop && createElement(ToolbarButton, {
onClick: () => setIsEditingImage(true),
icon: crop,
label: __('Crop')
}), externalBlob && createElement(ToolbarButton, {
onClick: uploadExternal,
icon: upload,
label: __('Upload external image')
}), !multiImageSelection && canInsertCover && createElement(ToolbarButton, {
icon: overlayText,
label: __('Add text over image'),
onClick: switchToCover
})), !multiImageSelection && !isEditingImage && createElement(BlockControls, {
group: "other"
}, createElement(MediaReplaceFlow, {
mediaId: id,
mediaURL: url,
allowedTypes: ALLOWED_MEDIA_TYPES,
accept: "image/*",
onSelect: onSelectImage,
onSelectURL: onSelectURL,
onError: onUploadError
})), createElement(InspectorControls, null, createElement(PanelBody, {
title: __('Settings')
}, !multiImageSelection && createElement(TextareaControl, {
label: __('Alt text (alternative text)'),
value: alt,
onChange: updateAlt,
help: createElement(Fragment, null, createElement(ExternalLink, {
href: "https://www.w3.org/WAI/tutorials/images/decision-tree"
}, __('Describe the purpose of the image')), __('Leave empty if the image is purely decorative.'))
}), createElement(ImageSizeControl, {
onChangeImage: updateImage,
onChange: value => setAttributes(value),
slug: sizeSlug,
width: width,
height: height,
imageSizeOptions: imageSizeOptions,
isResizable: isResizable,
imageWidth: naturalWidth,
imageHeight: naturalHeight
}))), createElement(InspectorControls, {
__experimentalGroup: "advanced"
}, createElement(TextControl, {
label: __('Title attribute'),
value: title || '',
onChange: onSetTitle,
help: createElement(Fragment, null, __('Describe the role of this image on the page.'), createElement(ExternalLink, {
href: "https://www.w3.org/TR/html52/dom.html#the-title-attribute"
}, __('(Note: many devices and browsers do not display this text.)')))
})));
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');
}
const borderProps = useBorderProps(attributes);
const isRounded = (_attributes$className = attributes.className) === null || _attributes$className === void 0 ? void 0 : _attributes$className.includes('is-style-rounded');
const hasCustomBorder = !!borderProps.className || !isEmpty(borderProps.style);
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 */
createElement(Fragment, null, createElement("img", {
src: temporaryURL || url,
alt: defaultedAlt,
onError: () => onImageError(),
onLoad: event => {
var _event$target, _event$target2;
setLoadedNaturalSize({
loadedNaturalWidth: (_event$target = event.target) === null || _event$target === void 0 ? void 0 : _event$target.naturalWidth,
loadedNaturalHeight: (_event$target2 = event.target) === null || _event$target2 === void 0 ? void 0 : _event$target2.naturalHeight
});
},
ref: imageRef,
className: borderProps.className,
style: borderProps.style
}), temporaryURL && createElement(Spinner, null))
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */
;
let imageWidthWithinContainer;
let imageHeightWithinContainer;
if (clientWidth && naturalWidth && naturalHeight) {
const exceedMaxWidth = naturalWidth > clientWidth;
const ratio = naturalHeight / naturalWidth;
imageWidthWithinContainer = exceedMaxWidth ? clientWidth : naturalWidth;
imageHeightWithinContainer = exceedMaxWidth ? clientWidth * ratio : naturalHeight;
}
if (canEditImage && isEditingImage) {
img = createElement(ImageEditor, {
borderProps: isRounded ? undefined : borderProps,
url: url,
width: width,
height: height,
clientWidth: clientWidth,
naturalHeight: naturalHeight,
naturalWidth: naturalWidth
});
} else if (!isResizable || !imageWidthWithinContainer) {
img = createElement("div", {
style: {
width,
height
}
}, img);
} else {
const currentWidth = width || imageWidthWithinContainer;
const currentHeight = height || imageHeightWithinContainer;
const ratio = naturalWidth / naturalHeight;
const minWidth = naturalWidth < naturalHeight ? MIN_SIZE : MIN_SIZE * ratio;
const minHeight = naturalHeight < naturalWidth ? MIN_SIZE : MIN_SIZE / ratio; // With the current implementation of ResizableBox, an image needs an
// explicit pixel value for the max-width. In absence of being able to
// set the content-width, this max-width is currently dictated by the
// vanilla editor style. The following variable adds a buffer to this
// vanilla style, so 3rd party themes have some wiggleroom. This does,
// in most cases, allow you to scale the image beyond the width of the
// main column, though not infinitely.
// @todo It would be good to revisit this once a content-width variable
// becomes available.
const maxWidthBuffer = maxWidth * 2.5;
let showRightHandle = false;
let showLeftHandle = false;
/* eslint-disable no-lonely-if */
// See https://github.com/WordPress/gutenberg/issues/7584.
if (align === 'center') {
// When the image is centered, show both handles.
showRightHandle = true;
showLeftHandle = true;
} else if (isRTL()) {
// In RTL mode the image is on the right by default.
// Show the right handle and hide the left handle only when it is
// aligned left. Otherwise always show the left handle.
if (align === 'left') {
showRightHandle = true;
} else {
showLeftHandle = true;
}
} else {
// Show the left handle and hide the right handle only when the
// image is aligned right. Otherwise always show the right handle.
if (align === 'right') {
showLeftHandle = true;
} else {
showRightHandle = true;
}
}
/* eslint-enable no-lonely-if */
img = createElement(ResizableBox, {
size: {
width: width !== null && width !== void 0 ? width : 'auto',
height: height && !hasCustomBorder ? height : 'auto'
},
showHandle: isSelected,
minWidth: minWidth,
maxWidth: maxWidthBuffer,
minHeight: minHeight,
maxHeight: maxWidthBuffer / ratio,
lockAspectRatio: true,
enable: {
top: false,
right: showRightHandle,
bottom: true,
left: showLeftHandle
},
onResizeStart: onResizeStart,
onResizeStop: (event, direction, elt, delta) => {
onResizeStop();
setAttributes({
width: parseInt(currentWidth + delta.width, 10),
height: parseInt(currentHeight + delta.height, 10)
});
}
}, img);
}
return createElement(ImageEditingProvider, {
id: id,
url: url,
naturalWidth: naturalWidth,
naturalHeight: naturalHeight,
clientWidth: clientWidth,
onSaveImage: imageAttributes => setAttributes(imageAttributes),
isEditing: isEditingImage,
onFinishEditing: () => setIsEditingImage(false)
}, !temporaryURL && controls, img, (!RichText.isEmpty(caption) || isSelected) && createElement(RichText, {
className: __experimentalGetElementClassName('caption'),
ref: captionRef,
tagName: "figcaption",
"aria-label": __('Image caption text'),
placeholder: __('Add caption'),
value: caption,
onChange: value => setAttributes({
caption: value
}),
inlineToolbar: true,
__unstableOnSplitAtEnd: () => insertBlocksAfter(createBlock(getDefaultBlockName()))
}));
}
//# sourceMappingURL=image.js.map