From 90f12f2e5a41115a9a756f9dd38054736080d4f9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 22 Feb 2018 00:35:46 +0100 Subject: [PATCH] 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 --- app/controllers/api/v1/media_controller.rb | 2 +- app/javascript/images/reticle.png | Bin 0 -> 3053 bytes app/javascript/mastodon/actions/compose.js | 4 +- .../mastodon/components/media_gallery.js | 71 ++++++++-- .../features/compose/components/upload.js | 20 ++- .../compose/containers/upload_container.js | 7 +- .../ui/components/focal_point_modal.js | 122 ++++++++++++++++++ .../features/ui/components/modal_root.js | 2 + .../mastodon/features/video/index.js | 6 +- app/javascript/mastodon/reducers/compose.js | 2 +- .../styles/mastodon/components.scss | 69 +++++++++- app/lib/activitypub/activity/create.rb | 2 +- app/lib/activitypub/adapter.rb | 1 + app/models/media_attachment.rb | 20 ++- .../activitypub/image_serializer.rb | 9 ++ 15 files changed, 307 insertions(+), 30 deletions(-) create mode 100644 app/javascript/images/reticle.png create mode 100644 app/javascript/mastodon/features/ui/components/focal_point_modal.js diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 9f330f0df..d4e6337e7 100644 --- 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 new file mode 100644 index 0000000000000000000000000000000000000000..998994f5c00a37ee9b422af049ba1320e98f1ce9 GIT binary patch literal 3053 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 ZL7pthq3q3=v{?KBcX#!q7rTTU{ttCeA~OI0 literal 0 HcmV?d00001 diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8a35049b3..1732ff189 100644 --- 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 index a3ffc45ea..9e1bb77c2 100644 --- 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 = ( - {attachment.get('description')} + {attachment.get('description')} ); } 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 = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } } return ( -
+
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index 3a3d17710..61b2d19e0 100644 --- 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 (
{({ scale }) => ( -
- +
+
+ + {media.get('type') === 'image' && } +