commit: 90f12f2e5a41115a9a756f9dd38054736080d4f9
parent: d3a62d263703142250d7d59335394a8e2a599ed4
Author: Eugen Rochko <eugen@zeonfederated.com>
Date: Thu, 22 Feb 2018 00:35:46 +0100
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
Diffstat:
15 files changed, 307 insertions(+), 30 deletions(-)
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
@@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
private
def media_params
- params.permit(:file, :description)
+ params.permit(:file, :description, :focus)
end
def file_type_error
diff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png
Binary files differ.
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
@@ -178,11 +178,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));
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
@@ -12,6 +12,26 @@ const messages = defineMessages({
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 {
static contextTypes = {
@@ -24,6 +44,8 @@ class Item extends React.PureComponent {
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
+ containerWidth: PropTypes.number,
+ containerHeight: PropTypes.number,
};
static defaultProps = {
@@ -62,7 +84,7 @@ class Item extends React.PureComponent {
}
render () {
- const { attachment, index, size, standalone } = this.props;
+ const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
let width = 50;
let height = 100;
@@ -116,16 +138,40 @@ class Item extends React.PureComponent {
let thumbnail = '';
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 originalUrl = attachment.get('url');
- const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+ const originalHeight = attachment.getIn(['meta', 'original', 'height']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
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 = (
<a
@@ -134,7 +180,14 @@ class Item extends React.PureComponent {
onClick={this.handleClick}
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>
);
} else if (attachment.get('type') === 'gifv') {
@@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
}
handleRef = (node) => {
- if (node && this.isStandaloneEligible()) {
+ if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to
this.setState({
width: node.offsetWidth,
@@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
} 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 (
- <div className='media-gallery' style={style}>
+ <div className='media-gallery' style={style} ref={this.handleRef}>
<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} />
</div>
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
@@ -1,15 +1,13 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
-import IconButton from '../../../components/icon_button';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
- undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
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,
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
+ onOpenFocalPoint: PropTypes.func.isRequired,
};
state = {
@@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
this.props.onUndo(this.props.media.get('id'));
}
+ handleFocalPointClick = () => {
+ this.props.onOpenFocalPoint(this.props.media.get('id'));
+ }
+
handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value });
}
@@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
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 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' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<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')})` }}>
- <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+ <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 })}>
+ <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 })}>
<label>
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
+import { openModal } from '../../../actions/modal';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
},
onDescriptionChange: (id, description) => {
- dispatch(changeUploadCompose(id, description));
+ dispatch(changeUploadCompose(id, { description }));
+ },
+
+ onOpenFocalPoint: id => {
+ dispatch(openModal('FOCAL_POINT', { id }));
},
});
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -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>
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -8,6 +8,7 @@ import MediaModal from './media_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
import {
OnboardingModal,
MuteModal,
@@ -27,6 +28,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 {
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
@@ -30,7 +30,7 @@ const formatTime = secondsNum => {
return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
};
-const findElementPosition = el => {
+export const findElementPosition = el => {
let box;
if (el.getBoundingClientRect && el.parentNode) {
@@ -61,7 +61,7 @@ const findElementPosition = el => {
};
};
-const getPointerPosition = (el, event) => {
+export const getPointerPosition = (el, event) => {
const position = {};
const box = findElementPosition(el);
const boxW = el.offsetWidth;
@@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
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));
return position;
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
@@ -265,7 +265,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;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
@@ -433,6 +433,34 @@
min-width: 40%;
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 {
position: absolute;
z-index: 2;
@@ -470,10 +498,6 @@
opacity: 1;
}
}
-
- .icon-button {
- mix-blend-mode: difference;
- }
}
.compose-form__upload-thumbnail {
@@ -481,8 +505,9 @@
background-position: center;
background-size: cover;
background-repeat: no-repeat;
- height: 100px;
+ height: 140px;
width: 100%;
+ overflow: hidden;
}
}
@@ -4133,8 +4158,12 @@ a.status-card {
&,
img {
width: 100%;
- height: 100%;
+ }
+
+ img {
+ position: relative;
object-fit: cover;
+ height: auto;
}
}
@@ -4842,3 +4871,31 @@ noscript {
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;
+ }
+}
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
@@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
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
next if skip_download?
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
@@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
'conversation' => 'ostatus:conversation',
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
+ 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
},
],
}.freeze
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
@@ -91,6 +91,24 @@ class MediaAttachment < ApplicationRecord
shortcode
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 :set_shortcode
before_post_process :set_type_and_extension
@@ -168,7 +186,7 @@ class MediaAttachment < ApplicationRecord
end
def populate_meta
- meta = {}
+ meta = file.instance_read(:meta) || {}
file.queued_for_write.each do |style, file|
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
diff --git a/app/serializers/activitypub/image_serializer.rb b/app/serializers/activitypub/image_serializer.rb
@@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :type, :media_type, :url
+ attribute :focal_point, if: :focal_point?
def type
'Image'
@@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
def media_type
object.content_type
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