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'));
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>
)}
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';
dispatch(undoUploadCompose(id));
},
- onDescriptionChange: (id, description) => {
- dispatch(changeUploadCompose(id, { description }));
- },
-
onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
},
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 = {
focusX: 0,
focusY: 0,
dragging: false,
+ description: '',
+ dirty: false,
};
componentWillMount () {
document.removeEventListener('mouseup', this.handleMouseUp);
this.setState({ dragging: false });
- this.props.onSave(this.state.focusX, this.state.focusY);
}
updatePosition = 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>
);
onCloseVideo: PropTypes.func,
detailed: PropTypes.bool,
inline: PropTypes.bool,
+ editable: PropTypes.bool,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
}
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;
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}
>
<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}
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>
</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>
}
}
+ .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;
max-width: 100%;
border-radius: 4px;
+ &.editable {
+ border-radius: 0;
+ }
+
&:focus {
outline: 0;
}
}
}
-.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 {
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 {