Add media editing modal (#11563)
Move media description input to a modal and unite that modal with the focal point modal. Add a hint about choosing focal points, as well as a preview of a 16:9 thumbnail. Enable the user to watch the video next to the media description input. Fix #8320 Fix #6713
This commit is contained in:
		
					parent
					
						
							
								7ffec882ae
							
						
					
				
			
			
				commit
				
					
						23f7afa562
					
				
			
		
					 5 changed files with 156 additions and 125 deletions
				
			
		|  | @ -4,16 +4,11 @@ import PropTypes from 'prop-types'; | |||
| import Motion from '../../ui/util/optional_motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import classNames from 'classnames'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, | ||||
| }); | ||||
| 
 | ||||
| export default @injectIntl | ||||
| class Upload extends ImmutablePureComponent { | ||||
| export default class Upload extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|  | @ -21,30 +16,10 @@ class Upload extends ImmutablePureComponent { | |||
| 
 | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     onUndo: PropTypes.func.isRequired, | ||||
|     onDescriptionChange: PropTypes.func.isRequired, | ||||
|     onOpenFocalPoint: PropTypes.func.isRequired, | ||||
|     onSubmit: PropTypes.func.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|     hovered: false, | ||||
|     focused: false, | ||||
|     dirtyDescription: null, | ||||
|   }; | ||||
| 
 | ||||
|   handleKeyDown = (e) => { | ||||
|     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { | ||||
|       this.handleSubmit(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleSubmit = () => { | ||||
|     this.handleInputBlur(); | ||||
|     this.props.onSubmit(this.context.router.history); | ||||
|   } | ||||
| 
 | ||||
|   handleUndoClick = e => { | ||||
|     e.stopPropagation(); | ||||
|     this.props.onUndo(this.props.media.get('id')); | ||||
|  | @ -55,69 +30,21 @@ class Upload extends ImmutablePureComponent { | |||
|     this.props.onOpenFocalPoint(this.props.media.get('id')); | ||||
|   } | ||||
| 
 | ||||
|   handleInputChange = e => { | ||||
|     this.setState({ dirtyDescription: e.target.value }); | ||||
|   } | ||||
| 
 | ||||
|   handleMouseEnter = () => { | ||||
|     this.setState({ hovered: true }); | ||||
|   } | ||||
| 
 | ||||
|   handleMouseLeave = () => { | ||||
|     this.setState({ hovered: false }); | ||||
|   } | ||||
| 
 | ||||
|   handleInputFocus = () => { | ||||
|     this.setState({ focused: true }); | ||||
|   } | ||||
| 
 | ||||
|   handleClick = () => { | ||||
|     this.setState({ focused: true }); | ||||
|   } | ||||
| 
 | ||||
|   handleInputBlur = () => { | ||||
|     const { dirtyDescription } = this.state; | ||||
| 
 | ||||
|     this.setState({ focused: false, dirtyDescription: null }); | ||||
| 
 | ||||
|     if (dirtyDescription !== null) { | ||||
|       this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, media } = this.props; | ||||
|     const active          = this.state.hovered || this.state.focused; | ||||
|     const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; | ||||
|     const { media } = this.props; | ||||
|     const focusX = media.getIn(['meta', 'focus', 'x']); | ||||
|     const focusY = media.getIn(['meta', 'focus', 'y']); | ||||
|     const x = ((focusX /  2) + .5) * 100; | ||||
|     const y = ((focusY / -2) + .5) * 100; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'> | ||||
|       <div className='compose-form__upload' tabIndex='0' role='button'> | ||||
|         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> | ||||
|           {({ scale }) => ( | ||||
|             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> | ||||
|               <div className={classNames('compose-form__upload__actions', { active })}> | ||||
|               <div className={classNames('compose-form__upload__actions', { active: true })}> | ||||
|                 <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> | ||||
|                 {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>} | ||||
|               </div> | ||||
| 
 | ||||
|               <div className={classNames('compose-form__upload-description', { active })}> | ||||
|                 <label> | ||||
|                   <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> | ||||
| 
 | ||||
|                   <textarea | ||||
|                     placeholder={intl.formatMessage(messages.description)} | ||||
|                     value={description} | ||||
|                     maxLength={420} | ||||
|                     onFocus={this.handleInputFocus} | ||||
|                     onChange={this.handleInputChange} | ||||
|                     onBlur={this.handleInputBlur} | ||||
|                     onKeyDown={this.handleKeyDown} | ||||
|                   /> | ||||
|                 </label> | ||||
|                 <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { connect } from 'react-redux'; | ||||
| import Upload from '../components/upload'; | ||||
| import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; | ||||
| import { undoUploadCompose } from '../../../actions/compose'; | ||||
| import { openModal } from '../../../actions/modal'; | ||||
| import { submitCompose } from '../../../actions/compose'; | ||||
| 
 | ||||
|  | @ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({ | |||
|     dispatch(undoUploadCompose(id)); | ||||
|   }, | ||||
| 
 | ||||
|   onDescriptionChange: (id, description) => { | ||||
|     dispatch(changeUploadCompose(id, { description })); | ||||
|   }, | ||||
| 
 | ||||
|   onOpenFocalPoint: id => { | ||||
|     dispatch(openModal('FOCAL_POINT', { id })); | ||||
|   }, | ||||
|  |  | |||
|  | @ -1,11 +1,21 @@ | |||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { connect } from 'react-redux'; | ||||
| import ImageLoader from './image_loader'; | ||||
| import classNames from 'classnames'; | ||||
| import { changeUploadCompose } from '../../../actions/compose'; | ||||
| import { getPointerPosition } from '../../video'; | ||||
| import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | ||||
| import IconButton from 'mastodon/components/icon_button'; | ||||
| import Button from 'mastodon/components/button'; | ||||
| import Video from 'mastodon/features/video'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' }, | ||||
|   apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, | ||||
|   placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = (state, { id }) => ({ | ||||
|   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), | ||||
|  | @ -13,17 +23,20 @@ const mapStateToProps = (state, { id }) => ({ | |||
| 
 | ||||
| const mapDispatchToProps = (dispatch, { id }) => ({ | ||||
| 
 | ||||
|   onSave: (x, y) => { | ||||
|     dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); | ||||
|   onSave: (description, x, y) => { | ||||
|     dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default @connect(mapStateToProps, mapDispatchToProps) | ||||
| @injectIntl | ||||
| class FocalPointModal extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -32,6 +45,8 @@ class FocalPointModal extends ImmutablePureComponent { | |||
|     focusX: 0, | ||||
|     focusY: 0, | ||||
|     dragging: false, | ||||
|     description: '', | ||||
|     dirty: false, | ||||
|   }; | ||||
| 
 | ||||
|   componentWillMount () { | ||||
|  | @ -66,7 +81,6 @@ class FocalPointModal extends ImmutablePureComponent { | |||
|     document.removeEventListener('mouseup', this.handleMouseUp); | ||||
| 
 | ||||
|     this.setState({ dragging: false }); | ||||
|     this.props.onSave(this.state.focusX, this.state.focusY); | ||||
|   } | ||||
| 
 | ||||
|   updatePosition = e => { | ||||
|  | @ -74,47 +88,114 @@ class FocalPointModal extends ImmutablePureComponent { | |||
|     const focusX   = (x - .5) *  2; | ||||
|     const focusY   = (y - .5) * -2; | ||||
| 
 | ||||
|     this.setState({ x, y, focusX, focusY }); | ||||
|     this.setState({ x, y, focusX, focusY, dirty: true }); | ||||
|   } | ||||
| 
 | ||||
|   updatePositionFromMedia = media => { | ||||
|     const focusX      = media.getIn(['meta', 'focus', 'x']); | ||||
|     const focusY      = media.getIn(['meta', 'focus', 'y']); | ||||
|     const description = media.get('description') || ''; | ||||
| 
 | ||||
|     if (focusX && focusY) { | ||||
|       const x = (focusX /  2) + .5; | ||||
|       const y = (focusY / -2) + .5; | ||||
| 
 | ||||
|       this.setState({ x, y, focusX, focusY }); | ||||
|       this.setState({ | ||||
|         x, | ||||
|         y, | ||||
|         focusX, | ||||
|         focusY, | ||||
|         description, | ||||
|         dirty: false, | ||||
|       }); | ||||
|     } else { | ||||
|       this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 }); | ||||
|       this.setState({ | ||||
|         x: 0.5, | ||||
|         y: 0.5, | ||||
|         focusX: 0, | ||||
|         focusY: 0, | ||||
|         description, | ||||
|         dirty: false, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleChange = e => { | ||||
|     this.setState({ description: e.target.value, dirty: true }); | ||||
|   } | ||||
| 
 | ||||
|   handleSubmit = () => { | ||||
|     this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); | ||||
|     this.props.onClose(); | ||||
|   } | ||||
| 
 | ||||
|   setRef = c => { | ||||
|     this.node = c; | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { media } = this.props; | ||||
|     const { x, y, dragging } = this.state; | ||||
|     const { media, intl, onClose } = this.props; | ||||
|     const { x, y, dragging, description, dirty } = this.state; | ||||
| 
 | ||||
|     const width  = media.getIn(['meta', 'original', 'width']) || null; | ||||
|     const height = media.getIn(['meta', 'original', 'height']) || null; | ||||
|     const focals = ['image', 'gifv'].includes(media.get('type')); | ||||
| 
 | ||||
|     const previewRatio  = 16/9; | ||||
|     const previewWidth  = 200; | ||||
|     const previewHeight = previewWidth / previewRatio; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='modal-root__modal video-modal focal-point-modal'> | ||||
|         <div className={classNames('focal-point', { dragging })} ref={this.setRef}> | ||||
|           <ImageLoader | ||||
|             previewSrc={media.get('preview_url')} | ||||
|             src={media.get('url')} | ||||
|             width={width} | ||||
|             height={height} | ||||
|       <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> | ||||
|         <div className='report-modal__target'> | ||||
|           <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> | ||||
|           <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='report-modal__container'> | ||||
|           <div className='report-modal__comment'> | ||||
|             {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} | ||||
| 
 | ||||
|             <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label> | ||||
| 
 | ||||
|             <textarea | ||||
|               id='upload-modal__description' | ||||
|               className='setting-text light' | ||||
|               value={description} | ||||
|               onChange={this.handleChange} | ||||
|               autoFocus | ||||
|             /> | ||||
| 
 | ||||
|             <Button disabled={!dirty} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className='report-modal__statuses'> | ||||
|             {focals && ( | ||||
|               <div className={classNames('focal-point', { dragging })} ref={this.setRef}> | ||||
|                 {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />} | ||||
|                 {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />} | ||||
| 
 | ||||
|                 <div className='focal-point__preview'> | ||||
|                   <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> | ||||
|                   <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> | ||||
|                 </div> | ||||
| 
 | ||||
|                 <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> | ||||
|                 <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> | ||||
|               </div> | ||||
|             )} | ||||
| 
 | ||||
|             {['audio', 'video'].includes(media.get('type')) && ( | ||||
|               <Video | ||||
|                 preview={media.get('preview_url')} | ||||
|                 blurhash={media.get('blurhash')} | ||||
|                 src={media.get('url')} | ||||
|                 detailed | ||||
|                 editable | ||||
|               /> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -101,6 +101,7 @@ class Video extends React.PureComponent { | |||
|     onCloseVideo: PropTypes.func, | ||||
|     detailed: PropTypes.bool, | ||||
|     inline: PropTypes.bool, | ||||
|     editable: PropTypes.bool, | ||||
|     cacheWidth: PropTypes.func, | ||||
|     visible: PropTypes.bool, | ||||
|     onToggleVisibility: PropTypes.func, | ||||
|  | @ -375,7 +376,7 @@ class Video extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props; | ||||
|     const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable } = this.props; | ||||
|     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; | ||||
|     const progress = (currentTime / duration) * 100; | ||||
| 
 | ||||
|  | @ -413,7 +414,7 @@ class Video extends React.PureComponent { | |||
|     return ( | ||||
|       <div | ||||
|         role='menuitem' | ||||
|         className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })} | ||||
|         className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable })} | ||||
|         style={playerStyle} | ||||
|         ref={this.setPlayerRef} | ||||
|         onMouseEnter={this.handleMouseEnter} | ||||
|  | @ -423,7 +424,7 @@ class Video extends React.PureComponent { | |||
|       > | ||||
|         <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> | ||||
| 
 | ||||
|         {revealed && <video | ||||
|         {(revealed || editable) && <video | ||||
|           ref={this.setVideoRef} | ||||
|           src={src} | ||||
|           poster={preview} | ||||
|  | @ -445,7 +446,7 @@ class Video extends React.PureComponent { | |||
|           onVolumeChange={this.handleVolumeChange} | ||||
|         />} | ||||
| 
 | ||||
|         <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> | ||||
|         <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}> | ||||
|           <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> | ||||
|             <span className='spoiler-button__overlay__label'>{warning}</span> | ||||
|           </button> | ||||
|  | @ -489,7 +490,7 @@ class Video extends React.PureComponent { | |||
|             </div> | ||||
| 
 | ||||
|             <div className='video-player__buttons right'> | ||||
|               {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} | ||||
|               {(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} | ||||
|               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} | ||||
|               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} | ||||
|               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> | ||||
|  |  | |||
|  | @ -4567,6 +4567,14 @@ a.status-card.compact:hover { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .setting-text-label { | ||||
|     display: block; | ||||
|     color: $inverted-text-color; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     margin-bottom: 10px; | ||||
|   } | ||||
| 
 | ||||
|   .setting-toggle { | ||||
|     margin-top: 20px; | ||||
|     margin-bottom: 24px; | ||||
|  | @ -4960,6 +4968,10 @@ a.status-card.compact:hover { | |||
|   max-width: 100%; | ||||
|   border-radius: 4px; | ||||
| 
 | ||||
|   &.editable { | ||||
|     border-radius: 0; | ||||
|   } | ||||
| 
 | ||||
|   &:focus { | ||||
|     outline: 0; | ||||
|   } | ||||
|  | @ -5688,27 +5700,20 @@ noscript { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .focal-point-modal { | ||||
|   max-width: 80vw; | ||||
|   max-height: 80vh; | ||||
|   position: relative; | ||||
| } | ||||
| 
 | ||||
| .focal-point { | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   cursor: move; | ||||
|   overflow: hidden; | ||||
| 
 | ||||
|   &.dragging { | ||||
|     cursor: move; | ||||
|   } | ||||
| 
 | ||||
|   img { | ||||
|     max-width: 80vw; | ||||
|   img, | ||||
|   video { | ||||
|     display: block; | ||||
|     max-height: 80vh; | ||||
|     width: auto; | ||||
|     width: 100%; | ||||
|     height: auto; | ||||
|     margin: auto; | ||||
|     margin: 0; | ||||
|     object-fit: contain; | ||||
|     background: $base-shadow-color; | ||||
|   } | ||||
| 
 | ||||
|   &__reticle { | ||||
|  | @ -5728,6 +5733,27 @@ noscript { | |||
|     top: 0; | ||||
|     left: 0; | ||||
|   } | ||||
| 
 | ||||
|   &__preview { | ||||
|     position: absolute; | ||||
|     bottom: 10px; | ||||
|     right: 10px; | ||||
|     z-index: 2; | ||||
|     cursor: default; | ||||
| 
 | ||||
|     strong { | ||||
|       color: $primary-text-color; | ||||
|       font-size: 14px; | ||||
|       font-weight: 500; | ||||
|       display: block; | ||||
|       margin-bottom: 5px; | ||||
|     } | ||||
| 
 | ||||
|     div { | ||||
|       border-radius: 4px; | ||||
|       box-shadow: 0 0 14px rgba($base-shadow-color, 0.2); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .account__header__content { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue