Add focal points support in the composer

This commit is contained in:
Thibaut Girka 2018-08-18 11:01:53 +02:00 committed by ThibG
parent 96e58d01ad
commit 02adce1ad8
9 changed files with 240 additions and 16 deletions

View file

@ -211,11 +211,11 @@ export function uploadCompose(files) {
};
};
export function changeUploadCompose(id, description) {
export function changeUploadCompose(id, params) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));

View file

@ -103,7 +103,7 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeComposeAdvancedOption(option, value));
},
onChangeDescription(id, description) {
dispatch(changeUploadCompose(id, description));
dispatch(changeUploadCompose(id, { description }));
},
onChangeSensitivity() {
dispatch(changeComposeSensitivity());
@ -141,6 +141,9 @@ const mapDispatchToProps = (dispatch) => ({
onOpenDoodleModal() {
dispatch(openModal('DOODLE', { noEsc: true }));
},
onOpenFocalPointModal(id) {
dispatch(openModal('FOCAL_POINT', { id }));
},
onSelectSuggestion(position, token, suggestion) {
dispatch(selectComposeSuggestion(position, token, suggestion));
},
@ -339,6 +342,7 @@ class Composer extends React.Component {
onFetchSuggestions,
onOpenActionsModal,
onOpenDoodleModal,
onOpenFocalPointModal,
onUndoUpload,
onUpload,
privacy,
@ -397,6 +401,7 @@ class Composer extends React.Component {
intl={intl}
media={media}
onChangeDescription={onChangeDescription}
onOpenFocalPointModal={onOpenFocalPointModal}
onRemove={onUndoUpload}
progress={progress}
uploading={isUploading}

View file

@ -13,6 +13,7 @@ export default function ComposerUploadForm ({
intl,
media,
onChangeDescription,
onOpenFocalPointModal,
onRemove,
progress,
uploading,
@ -31,8 +32,12 @@ export default function ComposerUploadForm ({
key={item.get('id')}
id={item.get('id')}
intl={intl}
focusX={item.getIn(['meta', 'focus', 'x'])}
focusY={item.getIn(['meta', 'focus', 'y'])}
mediaType={item.get('type')}
preview={item.get('preview_url')}
onChangeDescription={onChangeDescription}
onOpenFocalPointModal={onOpenFocalPointModal}
onRemove={onRemove}
/>
))}
@ -46,8 +51,8 @@ export default function ComposerUploadForm ({
ComposerUploadForm.propTypes = {
intl: PropTypes.object.isRequired,
media: ImmutablePropTypes.list,
onChangeDescription: PropTypes.func,
onRemove: PropTypes.func,
onChangeDescription: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
progress: PropTypes.number,
uploading: PropTypes.bool,
};

View file

@ -25,6 +25,10 @@ const messages = defineMessages({
defaultMessage: 'Describe for the visually impaired',
id: 'upload_form.description',
},
crop: {
defaultMessage: 'Crop',
id: 'upload_form.focus',
},
});
// Handlers.
@ -77,6 +81,17 @@ const handlers = {
onRemove(id);
}
},
// Opens the focal point modal.
handleFocalPointClick () {
const {
id,
onOpenFocalPointModal,
} = this.props;
if (id && onOpenFocalPointModal) {
onOpenFocalPointModal(id);
}
},
};
// The component.
@ -102,11 +117,15 @@ export default class ComposerUploadFormItem extends React.PureComponent {
handleMouseEnter,
handleMouseLeave,
handleRemove,
handleFocalPointClick,
} = this.handlers;
const {
description,
intl,
preview,
focusX,
focusY,
mediaType,
} = this.props;
const {
focused,
@ -114,6 +133,8 @@ export default class ComposerUploadFormItem extends React.PureComponent {
dirtyDescription,
} = this.state;
const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
// The result.
return (
@ -136,15 +157,15 @@ export default class ComposerUploadFormItem extends React.PureComponent {
style={{
transform: `scale(${scale})`,
backgroundImage: preview ? `url(${preview})` : null,
backgroundPosition: `${x}% ${y}%`
}}
>
<IconButton
className='close'
icon='times'
onClick={handleRemove}
size={36}
title={intl.formatMessage(messages.undo)}
/>
<div className={classNames('composer--upload_form--actions', { active: hovered || focused })}>
<button className='icon-button' onClick={handleRemove}>
<i className='fa fa-times' /> <FormattedMessage {...messages.undo} />
</button>
{mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>}
</div>
<label>
<span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
<input
@ -171,7 +192,11 @@ ComposerUploadFormItem.propTypes = {
description: PropTypes.string,
id: PropTypes.string,
intl: PropTypes.object.isRequired,
onChangeDescription: PropTypes.func,
onRemove: PropTypes.func,
onChangeDescription: PropTypes.func.isRequired,
onOpenFocalPointModal: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
focusX: PropTypes.number,
focusY: PropTypes.number,
mediaType: PropTypes.string,
preview: PropTypes.string,
};

View file

@ -0,0 +1,122 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ImageLoader from './image_loader';
import classNames from 'classnames';
import { changeUploadCompose } from 'flavours/glitch/actions/compose';
import { getPointerPosition } from 'flavours/glitch/features/video';
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)}` }));
},
});
@connect(mapStateToProps, mapDispatchToProps)
export default class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
};
state = {
x: 0,
y: 0,
focusX: 0,
focusY: 0,
dragging: false,
};
componentWillMount () {
this.updatePositionFromMedia(this.props.media);
}
componentWillReceiveProps (nextProps) {
if (this.props.media.get('id') !== nextProps.media.get('id')) {
this.updatePositionFromMedia(nextProps.media);
}
}
componentWillUnmount () {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
this.updatePosition(e);
this.setState({ dragging: true });
}
handleMouseMove = e => {
this.updatePosition(e);
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove);
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 });
}
updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
if (focusX && focusY) {
const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5;
this.setState({ x, y, focusX, focusY });
} else {
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
}
}
setRef = c => {
this.node = c;
}
render () {
const { media } = this.props;
const { x, y, dragging } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
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>
</div>
);
}
}

View file

@ -11,6 +11,7 @@ import BoostModal from './boost_modal';
import FavouriteModal from './favourite_modal';
import DoodleModal from './doodle_modal';
import ConfirmationModal from './confirmation_modal';
import FocalPointModal from './focal_point_modal';
import {
OnboardingModal,
MuteModal,
@ -34,6 +35,7 @@ const MODAL_COMPONENTS = {
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
};
export default class ModalRoot extends React.PureComponent {

View file

@ -371,7 +371,7 @@ export default function compose(state = initialState, action) {
.set('is_submitting', false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return item.set('description', action.media.description);
return fromJS(action.media);
}
return item;

View file

@ -255,11 +255,12 @@
& > div {
position: relative;
border-radius: 4px;
height: 100px;
height: 140px;
width: 100%;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
overflow: hidden;
input {
display: block;
@ -298,6 +299,34 @@
}
}
.composer--upload_form--actions {
background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
display: flex;
align-items: flex-start;
justify-content: space-between;
opacity: 0;
transition: opacity .1s ease;
.icon-button {
flex: 0 1 auto;
color: $ui-secondary-color;
font-size: 14px;
font-weight: 500;
padding: 10px;
font-family: inherit;
&:hover,
&:focus,
&:active {
color: lighten($ui-secondary-color, 4%);
}
}
&.active {
opacity: 1;
}
}
.composer--upload_form--progress {
display: flex;
padding: 10px;

View file

@ -763,3 +763,39 @@
}
}
}
.focal-point {
position: relative;
cursor: pointer;
overflow: hidden;
&.dragging {
cursor: move;
}
img {
max-width: 80vw;
max-height: 80vh;
width: auto;
height: auto;
margin: auto;
}
&__reticle {
position: absolute;
width: 100px;
height: 100px;
transform: translate(-50%, -50%);
background: url('~/images/reticle.png') no-repeat 0 0;
border-radius: 50%;
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
}
&__overlay {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
}