UNPKG

20 kBJavaScriptView Raw
1/**
2 * External dependencies
3 */
4import classnames from 'classnames';
5import {
6 compact,
7 get,
8 isEmpty,
9 map,
10 last,
11 pick,
12} from 'lodash';
13
14/**
15 * WordPress dependencies
16 */
17import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob';
18import {
19 Button,
20 ButtonGroup,
21 IconButton,
22 PanelBody,
23 ResizableBox,
24 SelectControl,
25 Spinner,
26 TextareaControl,
27 TextControl,
28 ToggleControl,
29 Toolbar,
30 withNotices,
31} from '@wordpress/components';
32import { compose } from '@wordpress/compose';
33import { withSelect } from '@wordpress/data';
34import {
35 BlockAlignmentToolbar,
36 BlockControls,
37 BlockIcon,
38 InspectorControls,
39 MediaPlaceholder,
40 MediaUpload,
41 MediaUploadCheck,
42 RichText,
43} from '@wordpress/block-editor';
44import { mediaUpload } from '@wordpress/editor';
45import { Component, Fragment } from '@wordpress/element';
46import { __, sprintf } from '@wordpress/i18n';
47import { getPath } from '@wordpress/url';
48import { withViewportMatch } from '@wordpress/viewport';
49
50/**
51 * Internal dependencies
52 */
53import { createUpgradedEmbedBlock } from '../embed/util';
54import icon from './icon';
55import ImageSize from './image-size';
56
57/**
58 * Module constants
59 */
60const MIN_SIZE = 20;
61const LINK_DESTINATION_NONE = 'none';
62const LINK_DESTINATION_MEDIA = 'media';
63const LINK_DESTINATION_ATTACHMENT = 'attachment';
64const LINK_DESTINATION_CUSTOM = 'custom';
65const NEW_TAB_REL = 'noreferrer noopener';
66const ALLOWED_MEDIA_TYPES = [ 'image' ];
67
68export const pickRelevantMediaFiles = ( image ) => {
69 const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] );
70 imageProps.url = get( image, [ 'sizes', 'large', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || image.url;
71 return imageProps;
72};
73
74/**
75 * Is the URL a temporary blob URL? A blob URL is one that is used temporarily
76 * while the image is being uploaded and will not have an id yet allocated.
77 *
78 * @param {number=} id The id of the image.
79 * @param {string=} url The url of the image.
80 *
81 * @return {boolean} Is the URL a Blob URL
82 */
83const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url );
84
85/**
86 * Is the url for the image hosted externally. An externally hosted image has no id
87 * and is not a blob url.
88 *
89 * @param {number=} id The id of the image.
90 * @param {string=} url The url of the image.
91 *
92 * @return {boolean} Is the url an externally hosted url?
93 */
94const isExternalImage = ( id, url ) => url && ! id && ! isBlobURL( url );
95
96class ImageEdit extends Component {
97 constructor( { attributes } ) {
98 super( ...arguments );
99 this.updateAlt = this.updateAlt.bind( this );
100 this.updateAlignment = this.updateAlignment.bind( this );
101 this.onFocusCaption = this.onFocusCaption.bind( this );
102 this.onImageClick = this.onImageClick.bind( this );
103 this.onSelectImage = this.onSelectImage.bind( this );
104 this.onSelectURL = this.onSelectURL.bind( this );
105 this.updateImageURL = this.updateImageURL.bind( this );
106 this.updateWidth = this.updateWidth.bind( this );
107 this.updateHeight = this.updateHeight.bind( this );
108 this.updateDimensions = this.updateDimensions.bind( this );
109 this.onSetCustomHref = this.onSetCustomHref.bind( this );
110 this.onSetLinkClass = this.onSetLinkClass.bind( this );
111 this.onSetLinkRel = this.onSetLinkRel.bind( this );
112 this.onSetLinkDestination = this.onSetLinkDestination.bind( this );
113 this.onSetNewTab = this.onSetNewTab.bind( this );
114 this.getFilename = this.getFilename.bind( this );
115 this.toggleIsEditing = this.toggleIsEditing.bind( this );
116 this.onUploadError = this.onUploadError.bind( this );
117 this.onImageError = this.onImageError.bind( this );
118
119 this.state = {
120 captionFocused: false,
121 isEditing: ! attributes.url,
122 };
123 }
124
125 componentDidMount() {
126 const { attributes, setAttributes, noticeOperations } = this.props;
127 const { id, url = '' } = attributes;
128
129 if ( isTemporaryImage( id, url ) ) {
130 const file = getBlobByURL( url );
131
132 if ( file ) {
133 mediaUpload( {
134 filesList: [ file ],
135 onFileChange: ( [ image ] ) => {
136 setAttributes( pickRelevantMediaFiles( image ) );
137 },
138 allowedTypes: ALLOWED_MEDIA_TYPES,
139 onError: ( message ) => {
140 noticeOperations.createErrorNotice( message );
141 this.setState( { isEditing: true } );
142 },
143 } );
144 }
145 }
146 }
147
148 componentDidUpdate( prevProps ) {
149 const { id: prevID, url: prevURL = '' } = prevProps.attributes;
150 const { id, url = '' } = this.props.attributes;
151
152 if ( isTemporaryImage( prevID, prevURL ) && ! isTemporaryImage( id, url ) ) {
153 revokeBlobURL( url );
154 }
155
156 if ( ! this.props.isSelected && prevProps.isSelected && this.state.captionFocused ) {
157 this.setState( {
158 captionFocused: false,
159 } );
160 }
161 }
162
163 onUploadError( message ) {
164 const { noticeOperations } = this.props;
165 noticeOperations.createErrorNotice( message );
166 this.setState( {
167 isEditing: true,
168 } );
169 }
170
171 onSelectImage( media ) {
172 if ( ! media || ! media.url ) {
173 this.props.setAttributes( {
174 url: undefined,
175 alt: undefined,
176 id: undefined,
177 caption: undefined,
178 } );
179 return;
180 }
181
182 this.setState( {
183 isEditing: false,
184 } );
185
186 this.props.setAttributes( {
187 ...pickRelevantMediaFiles( media ),
188 width: undefined,
189 height: undefined,
190 } );
191 }
192
193 onSetLinkDestination( value ) {
194 let href;
195
196 if ( value === LINK_DESTINATION_NONE ) {
197 href = undefined;
198 } else if ( value === LINK_DESTINATION_MEDIA ) {
199 href = ( this.props.image && this.props.image.source_url ) || this.props.attributes.url;
200 } else if ( value === LINK_DESTINATION_ATTACHMENT ) {
201 href = this.props.image && this.props.image.link;
202 } else {
203 href = this.props.attributes.href;
204 }
205
206 this.props.setAttributes( {
207 linkDestination: value,
208 href,
209 } );
210 }
211
212 onSelectURL( newURL ) {
213 const { url } = this.props.attributes;
214
215 if ( newURL !== url ) {
216 this.props.setAttributes( {
217 url: newURL,
218 id: undefined,
219 } );
220 }
221
222 this.setState( {
223 isEditing: false,
224 } );
225 }
226
227 onImageError( url ) {
228 // Check if there's an embed block that handles this URL.
229 const embedBlock = createUpgradedEmbedBlock(
230 { attributes: { url } }
231 );
232 if ( undefined !== embedBlock ) {
233 this.props.onReplace( embedBlock );
234 }
235 }
236
237 onSetCustomHref( value ) {
238 this.props.setAttributes( { href: value } );
239 }
240
241 onSetLinkClass( value ) {
242 this.props.setAttributes( { linkClass: value } );
243 }
244
245 onSetLinkRel( value ) {
246 this.props.setAttributes( { rel: value } );
247 }
248
249 onSetNewTab( value ) {
250 const { rel } = this.props.attributes;
251 const linkTarget = value ? '_blank' : undefined;
252
253 let updatedRel = rel;
254 if ( linkTarget && ! rel ) {
255 updatedRel = NEW_TAB_REL;
256 } else if ( ! linkTarget && rel === NEW_TAB_REL ) {
257 updatedRel = undefined;
258 }
259
260 this.props.setAttributes( {
261 linkTarget,
262 rel: updatedRel,
263 } );
264 }
265
266 onFocusCaption() {
267 if ( ! this.state.captionFocused ) {
268 this.setState( {
269 captionFocused: true,
270 } );
271 }
272 }
273
274 onImageClick() {
275 if ( this.state.captionFocused ) {
276 this.setState( {
277 captionFocused: false,
278 } );
279 }
280 }
281
282 updateAlt( newAlt ) {
283 this.props.setAttributes( { alt: newAlt } );
284 }
285
286 updateAlignment( nextAlign ) {
287 const extraUpdatedAttributes = [ 'wide', 'full' ].indexOf( nextAlign ) !== -1 ?
288 { width: undefined, height: undefined } :
289 {};
290 this.props.setAttributes( { ...extraUpdatedAttributes, align: nextAlign } );
291 }
292
293 updateImageURL( url ) {
294 this.props.setAttributes( { url, width: undefined, height: undefined } );
295 }
296
297 updateWidth( width ) {
298 this.props.setAttributes( { width: parseInt( width, 10 ) } );
299 }
300
301 updateHeight( height ) {
302 this.props.setAttributes( { height: parseInt( height, 10 ) } );
303 }
304
305 updateDimensions( width = undefined, height = undefined ) {
306 return () => {
307 this.props.setAttributes( { width, height } );
308 };
309 }
310
311 getFilename( url ) {
312 const path = getPath( url );
313 if ( path ) {
314 return last( path.split( '/' ) );
315 }
316 }
317
318 getLinkDestinationOptions() {
319 return [
320 { value: LINK_DESTINATION_NONE, label: __( 'None' ) },
321 { value: LINK_DESTINATION_MEDIA, label: __( 'Media File' ) },
322 { value: LINK_DESTINATION_ATTACHMENT, label: __( 'Attachment Page' ) },
323 { value: LINK_DESTINATION_CUSTOM, label: __( 'Custom URL' ) },
324 ];
325 }
326
327 toggleIsEditing() {
328 this.setState( {
329 isEditing: ! this.state.isEditing,
330 } );
331 }
332
333 getImageSizeOptions() {
334 const { imageSizes, image } = this.props;
335 return compact( map( imageSizes, ( { name, slug } ) => {
336 const sizeUrl = get( image, [ 'media_details', 'sizes', slug, 'source_url' ] );
337 if ( ! sizeUrl ) {
338 return null;
339 }
340 return {
341 value: sizeUrl,
342 label: name,
343 };
344 } ) );
345 }
346
347 render() {
348 const { isEditing } = this.state;
349 const {
350 attributes,
351 setAttributes,
352 isLargeViewport,
353 isSelected,
354 className,
355 maxWidth,
356 noticeUI,
357 toggleSelection,
358 isRTL,
359 } = this.props;
360 const {
361 url,
362 alt,
363 caption,
364 align,
365 id,
366 href,
367 rel,
368 linkClass,
369 linkDestination,
370 width,
371 height,
372 linkTarget,
373 } = attributes;
374 const isExternal = isExternalImage( id, url );
375
376 let toolbarEditButton;
377 if ( url ) {
378 if ( isExternal ) {
379 toolbarEditButton = (
380 <Toolbar>
381 <IconButton
382 className="components-icon-button components-toolbar__control"
383 label={ __( 'Edit image' ) }
384 onClick={ this.toggleIsEditing }
385 icon="edit"
386 />
387 </Toolbar>
388 );
389 } else {
390 toolbarEditButton = (
391 <MediaUploadCheck>
392 <Toolbar>
393 <MediaUpload
394 onSelect={ this.onSelectImage }
395 allowedTypes={ ALLOWED_MEDIA_TYPES }
396 value={ id }
397 render={ ( { open } ) => (
398 <IconButton
399 className="components-toolbar__control"
400 label={ __( 'Edit image' ) }
401 icon="edit"
402 onClick={ open }
403 />
404 ) }
405 />
406 </Toolbar>
407 </MediaUploadCheck>
408 );
409 }
410 }
411
412 const controls = (
413 <BlockControls>
414 <BlockAlignmentToolbar
415 value={ align }
416 onChange={ this.updateAlignment }
417 />
418 { toolbarEditButton }
419 </BlockControls>
420 );
421
422 if ( isEditing || ! url ) {
423 const src = isExternal ? url : undefined;
424 return (
425 <Fragment>
426 { controls }
427 <MediaPlaceholder
428 icon={ <BlockIcon icon={ icon } /> }
429 className={ className }
430 onSelect={ this.onSelectImage }
431 onSelectURL={ this.onSelectURL }
432 notices={ noticeUI }
433 onError={ this.onUploadError }
434 accept="image/*"
435 allowedTypes={ ALLOWED_MEDIA_TYPES }
436 value={ { id, src } }
437 />
438 </Fragment>
439 );
440 }
441
442 const classes = classnames( className, {
443 'is-transient': isBlobURL( url ),
444 'is-resized': !! width || !! height,
445 'is-focused': isSelected,
446 } );
447
448 const isResizable = [ 'wide', 'full' ].indexOf( align ) === -1 && isLargeViewport;
449 const isLinkURLInputReadOnly = linkDestination !== LINK_DESTINATION_CUSTOM;
450 const imageSizeOptions = this.getImageSizeOptions();
451
452 const getInspectorControls = ( imageWidth, imageHeight ) => (
453 <InspectorControls>
454 <PanelBody title={ __( 'Image Settings' ) }>
455 <TextareaControl
456 label={ __( 'Alt Text (Alternative Text)' ) }
457 value={ alt }
458 onChange={ this.updateAlt }
459 help={ __( 'Alternative text describes your image to people who can’t see it. Add a short description with its key details.' ) }
460 />
461 { ! isEmpty( imageSizeOptions ) && (
462 <SelectControl
463 label={ __( 'Image Size' ) }
464 value={ url }
465 options={ imageSizeOptions }
466 onChange={ this.updateImageURL }
467 />
468 ) }
469 { isResizable && (
470 <div className="block-library-image__dimensions">
471 <p className="block-library-image__dimensions__row">
472 { __( 'Image Dimensions' ) }
473 </p>
474 <div className="block-library-image__dimensions__row">
475 <TextControl
476 type="number"
477 className="block-library-image__dimensions__width"
478 label={ __( 'Width' ) }
479 value={ width !== undefined ? width : imageWidth }
480 min={ 1 }
481 onChange={ this.updateWidth }
482 />
483 <TextControl
484 type="number"
485 className="block-library-image__dimensions__height"
486 label={ __( 'Height' ) }
487 value={ height !== undefined ? height : imageHeight }
488 min={ 1 }
489 onChange={ this.updateHeight }
490 />
491 </div>
492 <div className="block-library-image__dimensions__row">
493 <ButtonGroup aria-label={ __( 'Image Size' ) }>
494 { [ 25, 50, 75, 100 ].map( ( scale ) => {
495 const scaledWidth = Math.round( imageWidth * ( scale / 100 ) );
496 const scaledHeight = Math.round( imageHeight * ( scale / 100 ) );
497
498 const isCurrent = width === scaledWidth && height === scaledHeight;
499
500 return (
501 <Button
502 key={ scale }
503 isSmall
504 isPrimary={ isCurrent }
505 aria-pressed={ isCurrent }
506 onClick={ this.updateDimensions( scaledWidth, scaledHeight ) }
507 >
508 { scale }%
509 </Button>
510 );
511 } ) }
512 </ButtonGroup>
513 <Button
514 isSmall
515 onClick={ this.updateDimensions() }
516 >
517 { __( 'Reset' ) }
518 </Button>
519 </div>
520 </div>
521 ) }
522 </PanelBody>
523 <PanelBody title={ __( 'Link Settings' ) }>
524 <SelectControl
525 label={ __( 'Link To' ) }
526 value={ linkDestination }
527 options={ this.getLinkDestinationOptions() }
528 onChange={ this.onSetLinkDestination }
529 />
530 { linkDestination !== LINK_DESTINATION_NONE && (
531 <Fragment>
532 <TextControl
533 label={ __( 'Link URL' ) }
534 value={ href || '' }
535 onChange={ this.onSetCustomHref }
536 placeholder={ ! isLinkURLInputReadOnly ? 'https://' : undefined }
537 readOnly={ isLinkURLInputReadOnly }
538 />
539 <ToggleControl
540 label={ __( 'Open in New Tab' ) }
541 onChange={ this.onSetNewTab }
542 checked={ linkTarget === '_blank' } />
543 <TextControl
544 label={ __( 'Link CSS Class' ) }
545 value={ linkClass || '' }
546 onChange={ this.onSetLinkClass }
547 />
548 <TextControl
549 label={ __( 'Link Rel' ) }
550 value={ rel || '' }
551 onChange={ this.onSetLinkRel }
552 />
553 </Fragment>
554 ) }
555 </PanelBody>
556 </InspectorControls>
557 );
558
559 // Disable reason: Each block can be selected by clicking on it
560 /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
561 return (
562 <Fragment>
563 { controls }
564 <figure className={ classes }>
565 <ImageSize src={ url } dirtynessTrigger={ align }>
566 { ( sizes ) => {
567 const {
568 imageWidthWithinContainer,
569 imageHeightWithinContainer,
570 imageWidth,
571 imageHeight,
572 } = sizes;
573
574 const filename = this.getFilename( url );
575 let defaultedAlt;
576 if ( alt ) {
577 defaultedAlt = alt;
578 } else if ( filename ) {
579 defaultedAlt = sprintf( __( 'This image has an empty alt attribute; its file name is %s' ), filename );
580 } else {
581 defaultedAlt = __( 'This image has an empty alt attribute' );
582 }
583
584 const img = (
585 // Disable reason: Image itself is not meant to be interactive, but
586 // should direct focus to block.
587 /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
588 <Fragment>
589 <img src={ url } alt={ defaultedAlt } onClick={ this.onImageClick } onError={ () => this.onImageError( url ) } />
590 { isBlobURL( url ) && <Spinner /> }
591 </Fragment>
592 /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */
593 );
594
595 if ( ! isResizable || ! imageWidthWithinContainer ) {
596 return (
597 <Fragment>
598 { getInspectorControls( imageWidth, imageHeight ) }
599 <div style={ { width, height } }>
600 { img }
601 </div>
602 </Fragment>
603 );
604 }
605
606 const currentWidth = width || imageWidthWithinContainer;
607 const currentHeight = height || imageHeightWithinContainer;
608
609 const ratio = imageWidth / imageHeight;
610 const minWidth = imageWidth < imageHeight ? MIN_SIZE : MIN_SIZE * ratio;
611 const minHeight = imageHeight < imageWidth ? MIN_SIZE : MIN_SIZE / ratio;
612
613 // With the current implementation of ResizableBox, an image needs an explicit pixel value for the max-width.
614 // In absence of being able to set the content-width, this max-width is currently dictated by the vanilla editor style.
615 // The following variable adds a buffer to this vanilla style, so 3rd party themes have some wiggleroom.
616 // This does, in most cases, allow you to scale the image beyond the width of the main column, though not infinitely.
617 // @todo It would be good to revisit this once a content-width variable becomes available.
618 const maxWidthBuffer = maxWidth * 2.5;
619
620 let showRightHandle = false;
621 let showLeftHandle = false;
622
623 /* eslint-disable no-lonely-if */
624 // See https://github.com/WordPress/gutenberg/issues/7584.
625 if ( align === 'center' ) {
626 // When the image is centered, show both handles.
627 showRightHandle = true;
628 showLeftHandle = true;
629 } else if ( isRTL ) {
630 // In RTL mode the image is on the right by default.
631 // Show the right handle and hide the left handle only when it is aligned left.
632 // Otherwise always show the left handle.
633 if ( align === 'left' ) {
634 showRightHandle = true;
635 } else {
636 showLeftHandle = true;
637 }
638 } else {
639 // Show the left handle and hide the right handle only when the image is aligned right.
640 // Otherwise always show the right handle.
641 if ( align === 'right' ) {
642 showLeftHandle = true;
643 } else {
644 showRightHandle = true;
645 }
646 }
647 /* eslint-enable no-lonely-if */
648
649 return (
650 <Fragment>
651 { getInspectorControls( imageWidth, imageHeight ) }
652 <ResizableBox
653 size={
654 width && height ? {
655 width,
656 height,
657 } : undefined
658 }
659 minWidth={ minWidth }
660 maxWidth={ maxWidthBuffer }
661 minHeight={ minHeight }
662 maxHeight={ maxWidthBuffer / ratio }
663 lockAspectRatio
664 enable={ {
665 top: false,
666 right: showRightHandle,
667 bottom: true,
668 left: showLeftHandle,
669 } }
670 onResizeStart={ () => {
671 toggleSelection( false );
672 } }
673 onResizeStop={ ( event, direction, elt, delta ) => {
674 setAttributes( {
675 width: parseInt( currentWidth + delta.width, 10 ),
676 height: parseInt( currentHeight + delta.height, 10 ),
677 } );
678 toggleSelection( true );
679 } }
680 >
681 { img }
682 </ResizableBox>
683 </Fragment>
684 );
685 } }
686 </ImageSize>
687 { ( ! RichText.isEmpty( caption ) || isSelected ) && (
688 <RichText
689 tagName="figcaption"
690 placeholder={ __( 'Write caption…' ) }
691 value={ caption }
692 unstableOnFocus={ this.onFocusCaption }
693 onChange={ ( value ) => setAttributes( { caption: value } ) }
694 isSelected={ this.state.captionFocused }
695 inlineToolbar
696 />
697 ) }
698 </figure>
699 </Fragment>
700 );
701 /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
702 }
703}
704
705export default compose( [
706 withSelect( ( select, props ) => {
707 const { getMedia } = select( 'core' );
708 const { getSettings } = select( 'core/block-editor' );
709 const { id } = props.attributes;
710 const { maxWidth, isRTL, imageSizes } = getSettings();
711
712 return {
713 image: id ? getMedia( id ) : null,
714 maxWidth,
715 isRTL,
716 imageSizes,
717 };
718 } ),
719 withViewportMatch( { isLargeViewport: 'medium' } ),
720 withNotices,
721] )( ImageEdit );