UNPKG

@wordpress/block-library

Version:
517 lines (475 loc) 17.3 kB
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