You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

247 lines
8.8 KiB

  1. import React from 'react';
  2. import ImmutablePropTypes from 'react-immutable-proptypes';
  3. import PropTypes from 'prop-types';
  4. import ImmutablePureComponent from 'react-immutable-pure-component';
  5. import { connect } from 'react-redux';
  6. import classNames from 'classnames';
  7. import { changeUploadCompose } from '../../../actions/compose';
  8. import { getPointerPosition } from '../../video';
  9. import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
  10. import IconButton from 'mastodon/components/icon_button';
  11. import Button from 'mastodon/components/button';
  12. import Video from 'mastodon/features/video';
  13. import Textarea from 'react-textarea-autosize';
  14. import UploadProgress from 'mastodon/features/compose/components/upload_progress';
  15. import CharacterCounter from 'mastodon/features/compose/components/character_counter';
  16. import { length } from 'stringz';
  17. import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
  18. const messages = defineMessages({
  19. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  20. apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
  21. placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
  22. });
  23. const mapStateToProps = (state, { id }) => ({
  24. media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
  25. });
  26. const mapDispatchToProps = (dispatch, { id }) => ({
  27. onSave: (description, x, y) => {
  28. dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
  29. },
  30. });
  31. const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
  32. .replace(/\n/g, ' ')
  33. .replace(/\*\*\*\*\*\*/g, '\n\n');
  34. const assetHost = process.env.CDN_HOST || '';
  35. export default @connect(mapStateToProps, mapDispatchToProps)
  36. @injectIntl
  37. class FocalPointModal extends ImmutablePureComponent {
  38. static propTypes = {
  39. media: ImmutablePropTypes.map.isRequired,
  40. onClose: PropTypes.func.isRequired,
  41. intl: PropTypes.object.isRequired,
  42. };
  43. state = {
  44. x: 0,
  45. y: 0,
  46. focusX: 0,
  47. focusY: 0,
  48. dragging: false,
  49. description: '',
  50. dirty: false,
  51. progress: 0,
  52. };
  53. componentWillMount () {
  54. this.updatePositionFromMedia(this.props.media);
  55. }
  56. componentWillReceiveProps (nextProps) {
  57. if (this.props.media.get('id') !== nextProps.media.get('id')) {
  58. this.updatePositionFromMedia(nextProps.media);
  59. }
  60. }
  61. componentWillUnmount () {
  62. document.removeEventListener('mousemove', this.handleMouseMove);
  63. document.removeEventListener('mouseup', this.handleMouseUp);
  64. }
  65. handleMouseDown = e => {
  66. document.addEventListener('mousemove', this.handleMouseMove);
  67. document.addEventListener('mouseup', this.handleMouseUp);
  68. this.updatePosition(e);
  69. this.setState({ dragging: true });
  70. }
  71. handleMouseMove = e => {
  72. this.updatePosition(e);
  73. }
  74. handleMouseUp = () => {
  75. document.removeEventListener('mousemove', this.handleMouseMove);
  76. document.removeEventListener('mouseup', this.handleMouseUp);
  77. this.setState({ dragging: false });
  78. }
  79. updatePosition = e => {
  80. const { x, y } = getPointerPosition(this.node, e);
  81. const focusX = (x - .5) * 2;
  82. const focusY = (y - .5) * -2;
  83. this.setState({ x, y, focusX, focusY, dirty: true });
  84. }
  85. updatePositionFromMedia = media => {
  86. const focusX = media.getIn(['meta', 'focus', 'x']);
  87. const focusY = media.getIn(['meta', 'focus', 'y']);
  88. const description = media.get('description') || '';
  89. if (focusX && focusY) {
  90. const x = (focusX / 2) + .5;
  91. const y = (focusY / -2) + .5;
  92. this.setState({
  93. x,
  94. y,
  95. focusX,
  96. focusY,
  97. description,
  98. dirty: false,
  99. });
  100. } else {
  101. this.setState({
  102. x: 0.5,
  103. y: 0.5,
  104. focusX: 0,
  105. focusY: 0,
  106. description,
  107. dirty: false,
  108. });
  109. }
  110. }
  111. handleChange = e => {
  112. this.setState({ description: e.target.value, dirty: true });
  113. }
  114. handleSubmit = () => {
  115. this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
  116. this.props.onClose();
  117. }
  118. setRef = c => {
  119. this.node = c;
  120. }
  121. handleTextDetection = () => {
  122. const { media } = this.props;
  123. this.setState({ detecting: true });
  124. fetchTesseract().then(({ TesseractWorker }) => {
  125. const worker = new TesseractWorker({
  126. workerPath: `${assetHost}/packs/ocr/worker.min.js`,
  127. corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
  128. langPath: `${assetHost}/ocr/lang-data`,
  129. });
  130. worker.recognize(media.get('url'))
  131. .progress(({ progress }) => this.setState({ progress }))
  132. .finally(() => worker.terminate())
  133. .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
  134. .catch(() => this.setState({ detecting: false }));
  135. }).catch(() => this.setState({ detecting: false }));
  136. }
  137. render () {
  138. const { media, intl, onClose } = this.props;
  139. const { x, y, dragging, description, dirty, detecting, progress } = this.state;
  140. const width = media.getIn(['meta', 'original', 'width']) || null;
  141. const height = media.getIn(['meta', 'original', 'height']) || null;
  142. const focals = ['image', 'gifv'].includes(media.get('type'));
  143. const previewRatio = 16/9;
  144. const previewWidth = 200;
  145. const previewHeight = previewWidth / previewRatio;
  146. return (
  147. <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
  148. <div className='report-modal__target'>
  149. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
  150. <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
  151. </div>
  152. <div className='report-modal__container'>
  153. <div className='report-modal__comment'>
  154. {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
  155. <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>
  156. <div className='setting-text__wrapper'>
  157. <Textarea
  158. id='upload-modal__description'
  159. className='setting-text light'
  160. value={detecting ? '…' : description}
  161. onChange={this.handleChange}
  162. disabled={detecting}
  163. autoFocus
  164. />
  165. <div className='setting-text__modifiers'>
  166. <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
  167. </div>
  168. </div>
  169. <div className='setting-text__toolbar'>
  170. <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
  171. <CharacterCounter max={420} text={detecting ? '' : description} />
  172. </div>
  173. <Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
  174. </div>
  175. <div className='report-modal__statuses'>
  176. {focals && (
  177. <div className={classNames('focal-point', { dragging })} ref={this.setRef}>
  178. {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />}
  179. {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />}
  180. <div className='focal-point__preview'>
  181. <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
  182. <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
  183. </div>
  184. <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
  185. <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
  186. </div>
  187. )}
  188. {['audio', 'video'].includes(media.get('type')) && (
  189. <Video
  190. preview={media.get('preview_url')}
  191. blurhash={media.get('blurhash')}
  192. src={media.get('url')}
  193. detailed
  194. editable
  195. />
  196. )}
  197. </div>
  198. </div>
  199. </div>
  200. );
  201. }
  202. }