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
d3a62d2637
commit
90f12f2e5a
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