Focal points (#6520)
* Add focus param to media API, center thumbnails on focus point * Add UI for setting a focal point * Improve focal point icon on upload item * Use focal point in upload preview * Add focalPoint property to ActivityPub * Don't show focal point button for non-image attachments
This commit is contained in:
		
							parent
							
								
									c9ed272a4a
								
							
						
					
					
						commit
						865c7e7178
					
				
					 15 changed files with 307 additions and 30 deletions
				
			
		| 
						 | 
					@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def media_params
 | 
					  def media_params
 | 
				
			||||||
    params.permit(:file, :description)
 | 
					    params.permit(:file, :description, :focus)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def file_type_error
 | 
					  def file_type_error
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								app/javascript/images/reticle.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/javascript/images/reticle.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 3 KiB  | 
| 
						 | 
					@ -178,11 +178,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));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,6 +12,26 @@ const messages = defineMessages({
 | 
				
			||||||
  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 | 
					  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => {
 | 
				
			||||||
 | 
					  const containerCenter = Math.floor(containerSize / 2);
 | 
				
			||||||
 | 
					  const focusFactor     = (focusSize + 1) / 2;
 | 
				
			||||||
 | 
					  const scaledImage     = Math.floor(imageSize / containerToImageRatio);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let focus = Math.floor(focusFactor * scaledImage);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (toMinus) focus = scaledImage - focus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let focusOffset = focus - containerCenter;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const remainder = scaledImage - focus;
 | 
				
			||||||
 | 
					  const containerRemainder = containerSize - containerCenter;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder;
 | 
				
			||||||
 | 
					  if (focusOffset < 0) focusOffset = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (focusOffset * -100 / containerSize) + '%';
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Item extends React.PureComponent {
 | 
					class Item extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static contextTypes = {
 | 
					  static contextTypes = {
 | 
				
			||||||
| 
						 | 
					@ -24,6 +44,8 @@ class Item extends React.PureComponent {
 | 
				
			||||||
    index: PropTypes.number.isRequired,
 | 
					    index: PropTypes.number.isRequired,
 | 
				
			||||||
    size: PropTypes.number.isRequired,
 | 
					    size: PropTypes.number.isRequired,
 | 
				
			||||||
    onClick: PropTypes.func.isRequired,
 | 
					    onClick: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    containerWidth: PropTypes.number,
 | 
				
			||||||
 | 
					    containerHeight: PropTypes.number,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static defaultProps = {
 | 
					  static defaultProps = {
 | 
				
			||||||
| 
						 | 
					@ -62,7 +84,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { attachment, index, size, standalone } = this.props;
 | 
					    const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let width  = 50;
 | 
					    let width  = 50;
 | 
				
			||||||
    let height = 100;
 | 
					    let height = 100;
 | 
				
			||||||
| 
						 | 
					@ -116,16 +138,40 @@ class Item extends React.PureComponent {
 | 
				
			||||||
    let thumbnail = '';
 | 
					    let thumbnail = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (attachment.get('type') === 'image') {
 | 
					    if (attachment.get('type') === 'image') {
 | 
				
			||||||
      const previewUrl = attachment.get('preview_url');
 | 
					      const previewUrl   = attachment.get('preview_url');
 | 
				
			||||||
      const previewWidth = attachment.getIn(['meta', 'small', 'width']);
 | 
					      const previewWidth = attachment.getIn(['meta', 'small', 'width']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const originalUrl = attachment.get('url');
 | 
					      const originalUrl    = attachment.get('url');
 | 
				
			||||||
      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
 | 
					      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
 | 
				
			||||||
 | 
					      const originalHeight = attachment.getIn(['meta', 'original', 'height']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 | 
					      const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
 | 
					      const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
 | 
				
			||||||
      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
 | 
					      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const focusX     = attachment.getIn(['meta', 'focus', 'x']);
 | 
				
			||||||
 | 
					      const focusY     = attachment.getIn(['meta', 'focus', 'y']);
 | 
				
			||||||
 | 
					      const imageStyle = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) {
 | 
				
			||||||
 | 
					        const widthRatio  = originalWidth / (containerWidth * (width / 100));
 | 
				
			||||||
 | 
					        const heightRatio = originalHeight / (containerHeight * (height / 100));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let hShift = 0;
 | 
				
			||||||
 | 
					        let vShift = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (widthRatio > heightRatio) {
 | 
				
			||||||
 | 
					          hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX);
 | 
				
			||||||
 | 
					        } else if(widthRatio < heightRatio) {
 | 
				
			||||||
 | 
					          vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        imageStyle.top  = vShift;
 | 
				
			||||||
 | 
					        imageStyle.left = hShift;
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        imageStyle.height = '100%';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      thumbnail = (
 | 
					      thumbnail = (
 | 
				
			||||||
        <a
 | 
					        <a
 | 
				
			||||||
| 
						 | 
					@ -134,7 +180,14 @@ class Item extends React.PureComponent {
 | 
				
			||||||
          onClick={this.handleClick}
 | 
					          onClick={this.handleClick}
 | 
				
			||||||
          target='_blank'
 | 
					          target='_blank'
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
 | 
					          <img
 | 
				
			||||||
 | 
					            src={previewUrl}
 | 
				
			||||||
 | 
					            srcSet={srcSet}
 | 
				
			||||||
 | 
					            sizes={sizes}
 | 
				
			||||||
 | 
					            alt={attachment.get('description')}
 | 
				
			||||||
 | 
					            title={attachment.get('description')}
 | 
				
			||||||
 | 
					            style={imageStyle}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    } else if (attachment.get('type') === 'gifv') {
 | 
					    } else if (attachment.get('type') === 'gifv') {
 | 
				
			||||||
| 
						 | 
					@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleRef = (node) => {
 | 
					  handleRef = (node) => {
 | 
				
			||||||
    if (node && this.isStandaloneEligible()) {
 | 
					    if (node /*&& this.isStandaloneEligible()*/) {
 | 
				
			||||||
      // offsetWidth triggers a layout, so only calculate when we need to
 | 
					      // offsetWidth triggers a layout, so only calculate when we need to
 | 
				
			||||||
      this.setState({
 | 
					      this.setState({
 | 
				
			||||||
        width: node.offsetWidth,
 | 
					        width: node.offsetWidth,
 | 
				
			||||||
| 
						 | 
					@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
 | 
				
			||||||
      if (this.isStandaloneEligible()) {
 | 
					      if (this.isStandaloneEligible()) {
 | 
				
			||||||
        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
 | 
					        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
 | 
					        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} containerWidth={width} containerHeight={height} />);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='media-gallery' style={style}>
 | 
					      <div className='media-gallery' style={style} ref={this.handleRef}>
 | 
				
			||||||
        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
 | 
					        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
 | 
				
			||||||
          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
 | 
					          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,15 +1,13 @@
 | 
				
			||||||
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 PropTypes from 'prop-types';
 | 
				
			||||||
import IconButton from '../../../components/icon_button';
 | 
					 | 
				
			||||||
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 } from 'react-intl';
 | 
					import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
 | 
					 | 
				
			||||||
  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
 | 
					  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent {
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
    onUndo: PropTypes.func.isRequired,
 | 
					    onUndo: PropTypes.func.isRequired,
 | 
				
			||||||
    onDescriptionChange: PropTypes.func.isRequired,
 | 
					    onDescriptionChange: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    onOpenFocalPoint: PropTypes.func.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  state = {
 | 
				
			||||||
| 
						 | 
					@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
 | 
				
			||||||
    this.props.onUndo(this.props.media.get('id'));
 | 
					    this.props.onUndo(this.props.media.get('id'));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleFocalPointClick = () => {
 | 
				
			||||||
 | 
					    this.props.onOpenFocalPoint(this.props.media.get('id'));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleInputChange = e => {
 | 
					  handleInputChange = e => {
 | 
				
			||||||
    this.setState({ dirtyDescription: e.target.value });
 | 
					    this.setState({ dirtyDescription: e.target.value });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
 | 
				
			||||||
    const { intl, media } = this.props;
 | 
					    const { intl, media } = this.props;
 | 
				
			||||||
    const active          = this.state.hovered || this.state.focused;
 | 
					    const active          = this.state.hovered || this.state.focused;
 | 
				
			||||||
    const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
 | 
					    const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
 | 
				
			||||||
 | 
					    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 (
 | 
					    return (
 | 
				
			||||||
      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
 | 
					      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
 | 
				
			||||||
        <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')})` }}>
 | 
					            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
 | 
				
			||||||
              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
 | 
					              <div className={classNames('compose-form__upload__actions', { active })}>
 | 
				
			||||||
 | 
					                <button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
 | 
				
			||||||
 | 
					                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <div className={classNames('compose-form__upload-description', { active })}>
 | 
					              <div className={classNames('compose-form__upload-description', { active })}>
 | 
				
			||||||
                <label>
 | 
					                <label>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
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, changeUploadCompose } from '../../../actions/compose';
 | 
				
			||||||
 | 
					import { openModal } from '../../../actions/modal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onDescriptionChange: (id, description) => {
 | 
					  onDescriptionChange: (id, description) => {
 | 
				
			||||||
    dispatch(changeUploadCompose(id, description));
 | 
					    dispatch(changeUploadCompose(id, { description }));
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onOpenFocalPoint: id => {
 | 
				
			||||||
 | 
					    dispatch(openModal('FOCAL_POINT', { id }));
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 '../../../actions/compose';
 | 
				
			||||||
 | 
					import { getPointerPosition } from '../../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 media-modal'>
 | 
				
			||||||
 | 
					        <div className={classNames('media-modal__content 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>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import MediaModal from './media_modal';
 | 
				
			||||||
import VideoModal from './video_modal';
 | 
					import VideoModal from './video_modal';
 | 
				
			||||||
import BoostModal from './boost_modal';
 | 
					import BoostModal from './boost_modal';
 | 
				
			||||||
import ConfirmationModal from './confirmation_modal';
 | 
					import ConfirmationModal from './confirmation_modal';
 | 
				
			||||||
 | 
					import FocalPointModal from './focal_point_modal';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  OnboardingModal,
 | 
					  OnboardingModal,
 | 
				
			||||||
  MuteModal,
 | 
					  MuteModal,
 | 
				
			||||||
| 
						 | 
					@ -27,6 +28,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 {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,7 +30,7 @@ const formatTime = secondsNum => {
 | 
				
			||||||
  return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
 | 
					  return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const findElementPosition = el => {
 | 
					export const findElementPosition = el => {
 | 
				
			||||||
  let box;
 | 
					  let box;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (el.getBoundingClientRect && el.parentNode) {
 | 
					  if (el.getBoundingClientRect && el.parentNode) {
 | 
				
			||||||
| 
						 | 
					@ -61,7 +61,7 @@ const findElementPosition = el => {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getPointerPosition = (el, event) => {
 | 
					export const getPointerPosition = (el, event) => {
 | 
				
			||||||
  const position = {};
 | 
					  const position = {};
 | 
				
			||||||
  const box = findElementPosition(el);
 | 
					  const box = findElementPosition(el);
 | 
				
			||||||
  const boxW = el.offsetWidth;
 | 
					  const boxW = el.offsetWidth;
 | 
				
			||||||
| 
						 | 
					@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
 | 
				
			||||||
    pageY = event.changedTouches[0].pageY;
 | 
					    pageY = event.changedTouches[0].pageY;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
 | 
					  position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
 | 
				
			||||||
  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 | 
					  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return position;
 | 
					  return position;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -265,7 +265,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;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -433,6 +433,34 @@
 | 
				
			||||||
      min-width: 40%;
 | 
					      min-width: 40%;
 | 
				
			||||||
      margin: 5px;
 | 
					      margin: 5px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &__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;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      &-description {
 | 
					      &-description {
 | 
				
			||||||
        position: absolute;
 | 
					        position: absolute;
 | 
				
			||||||
        z-index: 2;
 | 
					        z-index: 2;
 | 
				
			||||||
| 
						 | 
					@ -470,10 +498,6 @@
 | 
				
			||||||
          opacity: 1;
 | 
					          opacity: 1;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					 | 
				
			||||||
      .icon-button {
 | 
					 | 
				
			||||||
        mix-blend-mode: difference;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    .compose-form__upload-thumbnail {
 | 
					    .compose-form__upload-thumbnail {
 | 
				
			||||||
| 
						 | 
					@ -481,8 +505,9 @@
 | 
				
			||||||
      background-position: center;
 | 
					      background-position: center;
 | 
				
			||||||
      background-size: cover;
 | 
					      background-size: cover;
 | 
				
			||||||
      background-repeat: no-repeat;
 | 
					      background-repeat: no-repeat;
 | 
				
			||||||
      height: 100px;
 | 
					      height: 140px;
 | 
				
			||||||
      width: 100%;
 | 
					      width: 100%;
 | 
				
			||||||
 | 
					      overflow: hidden;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4133,8 +4158,12 @@ a.status-card {
 | 
				
			||||||
  &,
 | 
					  &,
 | 
				
			||||||
  img {
 | 
					  img {
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
    height: 100%;
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  img {
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
    object-fit: cover;
 | 
					    object-fit: cover;
 | 
				
			||||||
 | 
					    height: auto;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4842,3 +4871,31 @@ noscript {
 | 
				
			||||||
    margin-bottom: 0;
 | 
					    margin-bottom: 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focal-point {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &.dragging {
 | 
				
			||||||
 | 
					    cursor: move;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			||||||
      next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 | 
					      next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
 | 
					      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
 | 
				
			||||||
      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence)
 | 
					      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
 | 
				
			||||||
      media_attachments << media_attachment
 | 
					      media_attachments << media_attachment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      next if skip_download?
 | 
					      next if skip_download?
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 | 
				
			||||||
        'conversation'              => 'ostatus:conversation',
 | 
					        'conversation'              => 'ostatus:conversation',
 | 
				
			||||||
        'toot'                      => 'http://joinmastodon.org/ns#',
 | 
					        'toot'                      => 'http://joinmastodon.org/ns#',
 | 
				
			||||||
        'Emoji'                     => 'toot:Emoji',
 | 
					        'Emoji'                     => 'toot:Emoji',
 | 
				
			||||||
 | 
					        'focalPoint'                => { '@container' => '@list', '@id' => 'toot:focalPoint' },
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  }.freeze
 | 
					  }.freeze
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -91,6 +91,24 @@ class MediaAttachment < ApplicationRecord
 | 
				
			||||||
    shortcode
 | 
					    shortcode
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def focus=(point)
 | 
				
			||||||
 | 
					    return if point.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    meta = file.instance_read(:meta) || {}
 | 
				
			||||||
 | 
					    meta['focus'] = { 'x' => x, 'y' => y }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    file.instance_write(:meta, meta)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def focus
 | 
				
			||||||
 | 
					    x = file.meta['focus']['x']
 | 
				
			||||||
 | 
					    y = file.meta['focus']['y']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "#{x},#{y}"
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  before_create :prepare_description, unless: :local?
 | 
					  before_create :prepare_description, unless: :local?
 | 
				
			||||||
  before_create :set_shortcode
 | 
					  before_create :set_shortcode
 | 
				
			||||||
  before_post_process :set_type_and_extension
 | 
					  before_post_process :set_type_and_extension
 | 
				
			||||||
| 
						 | 
					@ -168,7 +186,7 @@ class MediaAttachment < ApplicationRecord
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def populate_meta
 | 
					  def populate_meta
 | 
				
			||||||
    meta = {}
 | 
					    meta = file.instance_read(:meta) || {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    file.queued_for_write.each do |style, file|
 | 
					    file.queued_for_write.each do |style, file|
 | 
				
			||||||
      meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
 | 
					      meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
 | 
				
			||||||
  include RoutingHelper
 | 
					  include RoutingHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attributes :type, :media_type, :url
 | 
					  attributes :type, :media_type, :url
 | 
				
			||||||
 | 
					  attribute :focal_point, if: :focal_point?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def type
 | 
					  def type
 | 
				
			||||||
    'Image'
 | 
					    'Image'
 | 
				
			||||||
| 
						 | 
					@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
 | 
				
			||||||
  def media_type
 | 
					  def media_type
 | 
				
			||||||
    object.content_type
 | 
					    object.content_type
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def focal_point?
 | 
				
			||||||
 | 
					    object.responds_to?(:meta) && object.meta['focus'].is_a?(Hash)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def focal_point
 | 
				
			||||||
 | 
					    [object.meta['focus']['x'], object.meta['focus']['y']]
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in a new issue