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.

338 lines
11 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 Audio from 'mastodon/features/audio';
  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. import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
  19. import GIFV from 'mastodon/components/gifv';
  20. const messages = defineMessages({
  21. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  22. apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
  23. placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
  24. });
  25. const mapStateToProps = (state, { id }) => ({
  26. media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
  27. });
  28. const mapDispatchToProps = (dispatch, { id }) => ({
  29. onSave: (description, x, y) => {
  30. dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
  31. },
  32. });
  33. const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
  34. .replace(/\n/g, ' ')
  35. .replace(/\*\*\*\*\*\*/g, '\n\n');
  36. const assetHost = process.env.CDN_HOST || '';
  37. class ImageLoader extends React.PureComponent {
  38. static propTypes = {
  39. src: PropTypes.string.isRequired,
  40. width: PropTypes.number,
  41. height: PropTypes.number,
  42. };
  43. state = {
  44. loading: true,
  45. };
  46. componentDidMount() {
  47. const image = new Image();
  48. image.addEventListener('load', () => this.setState({ loading: false }));
  49. image.src = this.props.src;
  50. }
  51. render () {
  52. const { loading } = this.state;
  53. if (loading) {
  54. return <canvas width={this.props.width} height={this.props.height} />;
  55. } else {
  56. return <img {...this.props} alt='' />;
  57. }
  58. }
  59. }
  60. export default @connect(mapStateToProps, mapDispatchToProps)
  61. @injectIntl
  62. class FocalPointModal extends ImmutablePureComponent {
  63. static propTypes = {
  64. media: ImmutablePropTypes.map.isRequired,
  65. onClose: PropTypes.func.isRequired,
  66. intl: PropTypes.object.isRequired,
  67. };
  68. state = {
  69. x: 0,
  70. y: 0,
  71. focusX: 0,
  72. focusY: 0,
  73. dragging: false,
  74. description: '',
  75. dirty: false,
  76. progress: 0,
  77. loading: true,
  78. };
  79. componentWillMount () {
  80. this.updatePositionFromMedia(this.props.media);
  81. }
  82. componentWillReceiveProps (nextProps) {
  83. if (this.props.media.get('id') !== nextProps.media.get('id')) {
  84. this.updatePositionFromMedia(nextProps.media);
  85. }
  86. }
  87. componentWillUnmount () {
  88. document.removeEventListener('mousemove', this.handleMouseMove);
  89. document.removeEventListener('mouseup', this.handleMouseUp);
  90. }
  91. handleMouseDown = e => {
  92. document.addEventListener('mousemove', this.handleMouseMove);
  93. document.addEventListener('mouseup', this.handleMouseUp);
  94. this.updatePosition(e);
  95. this.setState({ dragging: true });
  96. }
  97. handleTouchStart = e => {
  98. document.addEventListener('touchmove', this.handleMouseMove);
  99. document.addEventListener('touchend', this.handleTouchEnd);
  100. this.updatePosition(e);
  101. this.setState({ dragging: true });
  102. }
  103. handleMouseMove = e => {
  104. this.updatePosition(e);
  105. }
  106. handleMouseUp = () => {
  107. document.removeEventListener('mousemove', this.handleMouseMove);
  108. document.removeEventListener('mouseup', this.handleMouseUp);
  109. this.setState({ dragging: false });
  110. }
  111. handleTouchEnd = () => {
  112. document.removeEventListener('touchmove', this.handleMouseMove);
  113. document.removeEventListener('touchend', this.handleTouchEnd);
  114. this.setState({ dragging: false });
  115. }
  116. updatePosition = e => {
  117. const { x, y } = getPointerPosition(this.node, e);
  118. const focusX = (x - .5) * 2;
  119. const focusY = (y - .5) * -2;
  120. this.setState({ x, y, focusX, focusY, dirty: true });
  121. }
  122. updatePositionFromMedia = media => {
  123. const focusX = media.getIn(['meta', 'focus', 'x']);
  124. const focusY = media.getIn(['meta', 'focus', 'y']);
  125. const description = media.get('description') || '';
  126. if (focusX && focusY) {
  127. const x = (focusX / 2) + .5;
  128. const y = (focusY / -2) + .5;
  129. this.setState({
  130. x,
  131. y,
  132. focusX,
  133. focusY,
  134. description,
  135. dirty: false,
  136. });
  137. } else {
  138. this.setState({
  139. x: 0.5,
  140. y: 0.5,
  141. focusX: 0,
  142. focusY: 0,
  143. description,
  144. dirty: false,
  145. });
  146. }
  147. }
  148. handleChange = e => {
  149. this.setState({ description: e.target.value, dirty: true });
  150. }
  151. handleKeyDown = (e) => {
  152. if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
  153. e.preventDefault();
  154. e.stopPropagation();
  155. this.setState({ description: e.target.value, dirty: true });
  156. this.handleSubmit();
  157. }
  158. }
  159. handleSubmit = () => {
  160. this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
  161. this.props.onClose();
  162. }
  163. setRef = c => {
  164. this.node = c;
  165. }
  166. handleTextDetection = () => {
  167. const { media } = this.props;
  168. this.setState({ detecting: true });
  169. fetchTesseract().then(({ TesseractWorker }) => {
  170. const worker = new TesseractWorker({
  171. workerPath: `${assetHost}/packs/ocr/worker.min.js`,
  172. corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
  173. langPath: `${assetHost}/ocr/lang-data`,
  174. });
  175. let media_url = media.get('url');
  176. if (window.URL && URL.createObjectURL) {
  177. try {
  178. media_url = URL.createObjectURL(media.get('file'));
  179. } catch (error) {
  180. console.error(error);
  181. }
  182. }
  183. worker.recognize(media_url)
  184. .progress(({ progress }) => this.setState({ progress }))
  185. .finally(() => worker.terminate())
  186. .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
  187. .catch(() => this.setState({ detecting: false }));
  188. }).catch(() => this.setState({ detecting: false }));
  189. }
  190. render () {
  191. const { media, intl, onClose } = this.props;
  192. const { x, y, dragging, description, dirty, detecting, progress } = this.state;
  193. const width = media.getIn(['meta', 'original', 'width']) || null;
  194. const height = media.getIn(['meta', 'original', 'height']) || null;
  195. const focals = ['image', 'gifv'].includes(media.get('type'));
  196. const previewRatio = 16/9;
  197. const previewWidth = 200;
  198. const previewHeight = previewWidth / previewRatio;
  199. let descriptionLabel = null;
  200. if (media.get('type') === 'audio') {
  201. descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />;
  202. } else if (media.get('type') === 'video') {
  203. descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />;
  204. } else {
  205. descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />;
  206. }
  207. return (
  208. <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
  209. <div className='report-modal__target'>
  210. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
  211. <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
  212. </div>
  213. <div className='report-modal__container'>
  214. <div className='report-modal__comment'>
  215. {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>}
  216. <label className='setting-text-label' htmlFor='upload-modal__description'>
  217. {descriptionLabel}
  218. </label>
  219. <div className='setting-text__wrapper'>
  220. <Textarea
  221. id='upload-modal__description'
  222. className='setting-text light'
  223. value={detecting ? '…' : description}
  224. onChange={this.handleChange}
  225. onKeyDown={this.handleKeyDown}
  226. disabled={detecting}
  227. autoFocus
  228. />
  229. <div className='setting-text__modifiers'>
  230. <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
  231. </div>
  232. </div>
  233. <div className='setting-text__toolbar'>
  234. <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>
  235. <CharacterCounter max={1500} text={detecting ? '' : description} />
  236. </div>
  237. <Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
  238. </div>
  239. <div className='focal-point-modal__content'>
  240. {focals && (
  241. <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
  242. {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
  243. {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
  244. <div className='focal-point__preview'>
  245. <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
  246. <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
  247. </div>
  248. <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
  249. <div className='focal-point__overlay' />
  250. </div>
  251. )}
  252. {media.get('type') === 'video' && (
  253. <Video
  254. preview={media.get('preview_url')}
  255. blurhash={media.get('blurhash')}
  256. src={media.get('url')}
  257. detailed
  258. inline
  259. editable
  260. />
  261. )}
  262. {media.get('type') === 'audio' && (
  263. <Audio
  264. src={media.get('url')}
  265. duration={media.getIn(['meta', 'original', 'duration'], 0)}
  266. height={150}
  267. preload
  268. editable
  269. />
  270. )}
  271. </div>
  272. </div>
  273. </div>
  274. );
  275. }
  276. }