Add focal points support in the composer

main
Thibaut Girka 2018-08-18 11:01:53 +02:00 committed by ThibG
parent 9782ac017b
commit 534439e73b
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) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); 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)); dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => { }).catch(error => {
dispatch(changeUploadComposeFail(id, error)); dispatch(changeUploadComposeFail(id, error));

View File

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

View File

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

View File

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

View File

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

View File

@ -255,11 +255,12 @@
& > div { & > div {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
height: 100px; height: 140px;
width: 100%; width: 100%;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
overflow: hidden;
input { input {
display: block; 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 { .composer--upload_form--progress {
display: flex; display: flex;
padding: 10px; 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;
}
}