focal_point_modal.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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 { TesseractWorker } from 'tesseract.js';
  14. import Textarea from 'react-textarea-autosize';
  15. import UploadProgress from 'mastodon/features/compose/components/upload_progress';
  16. import CharacterCounter from 'mastodon/features/compose/components/character_counter';
  17. import { length } from 'stringz';
  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. const worker = new TesseractWorker({
  124. workerPath: `${assetHost}/packs/ocr/worker.min.js`,
  125. corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
  126. langPath: `${assetHost}/ocr/lang-data`,
  127. });
  128. this.setState({ detecting: true });
  129. worker.recognize(media.get('url'))
  130. .progress(({ progress }) => this.setState({ progress }))
  131. .finally(() => worker.terminate())
  132. .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
  133. .catch(() => this.setState({ detecting: false }));
  134. }
  135. render () {
  136. const { media, intl, onClose } = this.props;
  137. const { x, y, dragging, description, dirty, detecting, progress } = this.state;
  138. const width = media.getIn(['meta', 'original', 'width']) || null;
  139. const height = media.getIn(['meta', 'original', 'height']) || null;
  140. const focals = ['image', 'gifv'].includes(media.get('type'));
  141. const previewRatio = 16/9;
  142. const previewWidth = 200;
  143. const previewHeight = previewWidth / previewRatio;
  144. return (
  145. <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
  146. <div className='report-modal__target'>
  147. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
  148. <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
  149. </div>
  150. <div className='report-modal__container'>
  151. <div className='report-modal__comment'>
  152. {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>}
  153. <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>
  154. <div className='setting-text__wrapper'>
  155. <Textarea
  156. id='upload-modal__description'
  157. className='setting-text light'
  158. value={detecting ? '…' : description}
  159. onChange={this.handleChange}
  160. disabled={detecting}
  161. autoFocus
  162. />
  163. <div className='setting-text__modifiers'>
  164. <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
  165. </div>
  166. </div>
  167. <div className='setting-text__toolbar'>
  168. <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>
  169. <CharacterCounter max={420} text={detecting ? '' : description} />
  170. </div>
  171. <Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
  172. </div>
  173. <div className='report-modal__statuses'>
  174. {focals && (
  175. <div className={classNames('focal-point', { dragging })} ref={this.setRef}>
  176. {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />}
  177. {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />}
  178. <div className='focal-point__preview'>
  179. <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
  180. <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
  181. </div>
  182. <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
  183. <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
  184. </div>
  185. )}
  186. {['audio', 'video'].includes(media.get('type')) && (
  187. <Video
  188. preview={media.get('preview_url')}
  189. blurhash={media.get('blurhash')}
  190. src={media.get('url')}
  191. detailed
  192. editable
  193. />
  194. )}
  195. </div>
  196. </div>
  197. </div>
  198. );
  199. }
  200. }