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:
Eugen Rochko 2019-08-14 04:07:32 +02:00 committed by GitHub
parent 7ffec882ae
commit 23f7afa562
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 156 additions and 125 deletions

View file

@ -4,16 +4,11 @@ import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
const messages = defineMessages({ export default class Upload extends ImmutablePureComponent {
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
export default @injectIntl
class Upload extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
@ -21,30 +16,10 @@ class Upload extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onOpenFocalPoint: 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 => { handleUndoClick = e => {
e.stopPropagation(); e.stopPropagation();
this.props.onUndo(this.props.media.get('id')); this.props.onUndo(this.props.media.get('id'));
@ -55,69 +30,21 @@ class Upload extends ImmutablePureComponent {
this.props.onOpenFocalPoint(this.props.media.get('id')); 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 () { render () {
const { intl, media } = this.props; const { media } = this.props;
const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
const focusX = media.getIn(['meta', 'focus', 'x']); const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']); const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100; const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100; const y = ((focusY / -2) + .5) * 100;
return ( 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 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> <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> <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>} <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></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>
</div> </div>
</div> </div>
)} )}

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Upload from '../components/upload'; import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; import { undoUploadCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { submitCompose } from '../../../actions/compose'; import { submitCompose } from '../../../actions/compose';
@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({
dispatch(undoUploadCompose(id)); dispatch(undoUploadCompose(id));
}, },
onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, { description }));
},
onOpenFocalPoint: id => { onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id })); dispatch(openModal('FOCAL_POINT', { id }));
}, },

View file

@ -1,11 +1,21 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImageLoader from './image_loader';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose'; import { changeUploadCompose } from '../../../actions/compose';
import { getPointerPosition } from '../../video'; 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 }) => ({ const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === 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 }) => ({ const mapDispatchToProps = (dispatch, { id }) => ({
onSave: (x, y) => { onSave: (description, x, y) => {
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
}, },
}); });
export default @connect(mapStateToProps, mapDispatchToProps) export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class FocalPointModal extends ImmutablePureComponent { class FocalPointModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}; };
state = { state = {
@ -32,6 +45,8 @@ class FocalPointModal extends ImmutablePureComponent {
focusX: 0, focusX: 0,
focusY: 0, focusY: 0,
dragging: false, dragging: false,
description: '',
dirty: false,
}; };
componentWillMount () { componentWillMount () {
@ -66,7 +81,6 @@ class FocalPointModal extends ImmutablePureComponent {
document.removeEventListener('mouseup', this.handleMouseUp); document.removeEventListener('mouseup', this.handleMouseUp);
this.setState({ dragging: false }); this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
} }
updatePosition = e => { updatePosition = e => {
@ -74,47 +88,114 @@ class FocalPointModal extends ImmutablePureComponent {
const focusX = (x - .5) * 2; const focusX = (x - .5) * 2;
const focusY = (y - .5) * -2; const focusY = (y - .5) * -2;
this.setState({ x, y, focusX, focusY }); this.setState({ x, y, focusX, focusY, dirty: true });
} }
updatePositionFromMedia = media => { updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']); const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']); const focusY = media.getIn(['meta', 'focus', 'y']);
const description = media.get('description') || '';
if (focusX && focusY) { if (focusX && focusY) {
const x = (focusX / 2) + .5; const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5; const y = (focusY / -2) + .5;
this.setState({ x, y, focusX, focusY }); this.setState({
x,
y,
focusX,
focusY,
description,
dirty: false,
});
} else { } 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 => { setRef = c => {
this.node = c; this.node = c;
} }
render () { render () {
const { media } = this.props; const { media, intl, onClose } = this.props;
const { x, y, dragging } = this.state; const { x, y, dragging, description, dirty } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null; const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || 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 ( return (
<div className='modal-root__modal video-modal focal-point-modal'> <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
<div className={classNames('focal-point', { dragging })} ref={this.setRef}> <div className='report-modal__target'>
<ImageLoader <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
previewSrc={media.get('preview_url')} <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
src={media.get('url')} </div>
width={width}
height={height} <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__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
</div> </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> </div>
); );
} }

View file

@ -101,6 +101,7 @@ class Video extends React.PureComponent {
onCloseVideo: PropTypes.func, onCloseVideo: PropTypes.func,
detailed: PropTypes.bool, detailed: PropTypes.bool,
inline: PropTypes.bool, inline: PropTypes.bool,
editable: PropTypes.bool,
cacheWidth: PropTypes.func, cacheWidth: PropTypes.func,
visible: PropTypes.bool, visible: PropTypes.bool,
onToggleVisibility: PropTypes.func, onToggleVisibility: PropTypes.func,
@ -375,7 +376,7 @@ class Video extends React.PureComponent {
} }
render () { 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 { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = (currentTime / duration) * 100; const progress = (currentTime / duration) * 100;
@ -413,7 +414,7 @@ class Video extends React.PureComponent {
return ( return (
<div <div
role='menuitem' 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} style={playerStyle}
ref={this.setPlayerRef} ref={this.setPlayerRef}
onMouseEnter={this.handleMouseEnter} 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 })} /> <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} ref={this.setVideoRef}
src={src} src={src}
poster={preview} poster={preview}
@ -445,7 +446,7 @@ class Video extends React.PureComponent {
onVolumeChange={this.handleVolumeChange} 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}> <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
<span className='spoiler-button__overlay__label'>{warning}</span> <span className='spoiler-button__overlay__label'>{warning}</span>
</button> </button>
@ -489,7 +490,7 @@ class Video extends React.PureComponent {
</div> </div>
<div className='video-player__buttons right'> <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>} {(!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>} {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> <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>

View file

@ -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 { .setting-toggle {
margin-top: 20px; margin-top: 20px;
margin-bottom: 24px; margin-bottom: 24px;
@ -4960,6 +4968,10 @@ a.status-card.compact:hover {
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
&.editable {
border-radius: 0;
}
&:focus { &:focus {
outline: 0; outline: 0;
} }
@ -5688,27 +5700,20 @@ noscript {
} }
} }
.focal-point-modal {
max-width: 80vw;
max-height: 80vh;
position: relative;
}
.focal-point { .focal-point {
position: relative; position: relative;
cursor: pointer; cursor: move;
overflow: hidden; overflow: hidden;
&.dragging { img,
cursor: move; video {
} display: block;
img {
max-width: 80vw;
max-height: 80vh; max-height: 80vh;
width: auto; width: 100%;
height: auto; height: auto;
margin: auto; margin: 0;
object-fit: contain;
background: $base-shadow-color;
} }
&__reticle { &__reticle {
@ -5728,6 +5733,27 @@ noscript {
top: 0; top: 0;
left: 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 { .account__header__content {