Skip to content
Permalink
Browse files

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
  • Loading branch information...
Gargron committed Aug 14, 2019
1 parent 7ffec88 commit 23f7afa562c49b24e979505680463bc712b11d94
@@ -4,47 +4,22 @@ 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,
};

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,29 +1,42 @@
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),
});

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,54 +81,120 @@ class FocalPointModal extends ImmutablePureComponent {
document.removeEventListener('mouseup', this.handleMouseUp);

this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
}

updatePosition = e => {
const { x, y } = getPointerPosition(this.node, e);
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>
);
@@ -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>

0 comments on commit 23f7afa

Please sign in to comment.
You can’t perform that action at this time.