diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2019-08-14 04:07:32 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-08-14 04:07:32 +0200 |
commit | 23f7afa562c49b24e979505680463bc712b11d94 (patch) | |
tree | 1ad51dd2ff2717ad5561fe28d78676b3f667f54a | |
parent | 7ffec882ae79a49d6eed42361f7e8d5de99e1d74 (diff) |
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
5 files changed, 157 insertions, 126 deletions
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index 629cbc36a..b9f0fbe3a 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -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> )} diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js index b6d81f03a..342b0c2a9 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_container.js @@ -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 })); }, diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 7488a3598..e15bf69d6 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -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,46 +88,113 @@ 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 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='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> - <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> + <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> ); diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 0acdd198d..da48c165e 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -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> diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 8de72d72e..f2967a398 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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 { |