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.

418 lines
14 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, uploadThumbnail } 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. import { me } from 'mastodon/initial_state';
  21. // eslint-disable-next-line import/no-extraneous-dependencies
  22. import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
  23. // eslint-disable-next-line import/extensions
  24. import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
  25. import { assetHost } from 'mastodon/utils/config';
  26. const messages = defineMessages({
  27. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  28. apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
  29. placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
  30. chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
  31. });
  32. const mapStateToProps = (state, { id }) => ({
  33. media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
  34. account: state.getIn(['accounts', me]),
  35. isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
  36. });
  37. const mapDispatchToProps = (dispatch, { id }) => ({
  38. onSave: (description, x, y) => {
  39. dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
  40. },
  41. onSelectThumbnail: files => {
  42. dispatch(uploadThumbnail(id, files[0]));
  43. },
  44. });
  45. const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
  46. .replace(/\n/g, ' ')
  47. .replace(/\*\*\*\*\*\*/g, '\n\n');
  48. class ImageLoader extends React.PureComponent {
  49. static propTypes = {
  50. src: PropTypes.string.isRequired,
  51. width: PropTypes.number,
  52. height: PropTypes.number,
  53. };
  54. state = {
  55. loading: true,
  56. };
  57. componentDidMount() {
  58. const image = new Image();
  59. image.addEventListener('load', () => this.setState({ loading: false }));
  60. image.src = this.props.src;
  61. }
  62. render () {
  63. const { loading } = this.state;
  64. if (loading) {
  65. return <canvas width={this.props.width} height={this.props.height} />;
  66. } else {
  67. return <img {...this.props} alt='' />;
  68. }
  69. }
  70. }
  71. export default @connect(mapStateToProps, mapDispatchToProps)
  72. @injectIntl
  73. class FocalPointModal extends ImmutablePureComponent {
  74. static propTypes = {
  75. media: ImmutablePropTypes.map.isRequired,
  76. account: ImmutablePropTypes.map.isRequired,
  77. isUploadingThumbnail: PropTypes.bool,
  78. onSave: PropTypes.func.isRequired,
  79. onSelectThumbnail: PropTypes.func.isRequired,
  80. onClose: PropTypes.func.isRequired,
  81. intl: PropTypes.object.isRequired,
  82. };
  83. state = {
  84. x: 0,
  85. y: 0,
  86. focusX: 0,
  87. focusY: 0,
  88. dragging: false,
  89. description: '',
  90. dirty: false,
  91. progress: 0,
  92. loading: true,
  93. ocrStatus: '',
  94. };
  95. componentWillMount () {
  96. this.updatePositionFromMedia(this.props.media);
  97. }
  98. componentWillReceiveProps (nextProps) {
  99. if (this.props.media.get('id') !== nextProps.media.get('id')) {
  100. this.updatePositionFromMedia(nextProps.media);
  101. }
  102. }
  103. componentWillUnmount () {
  104. document.removeEventListener('mousemove', this.handleMouseMove);
  105. document.removeEventListener('mouseup', this.handleMouseUp);
  106. }
  107. handleMouseDown = e => {
  108. document.addEventListener('mousemove', this.handleMouseMove);
  109. document.addEventListener('mouseup', this.handleMouseUp);
  110. this.updatePosition(e);
  111. this.setState({ dragging: true });
  112. }
  113. handleTouchStart = e => {
  114. document.addEventListener('touchmove', this.handleMouseMove);
  115. document.addEventListener('touchend', this.handleTouchEnd);
  116. this.updatePosition(e);
  117. this.setState({ dragging: true });
  118. }
  119. handleMouseMove = e => {
  120. this.updatePosition(e);
  121. }
  122. handleMouseUp = () => {
  123. document.removeEventListener('mousemove', this.handleMouseMove);
  124. document.removeEventListener('mouseup', this.handleMouseUp);
  125. this.setState({ dragging: false });
  126. }
  127. handleTouchEnd = () => {
  128. document.removeEventListener('touchmove', this.handleMouseMove);
  129. document.removeEventListener('touchend', this.handleTouchEnd);
  130. this.setState({ dragging: false });
  131. }
  132. updatePosition = e => {
  133. const { x, y } = getPointerPosition(this.node, e);
  134. const focusX = (x - .5) * 2;
  135. const focusY = (y - .5) * -2;
  136. this.setState({ x, y, focusX, focusY, dirty: true });
  137. }
  138. updatePositionFromMedia = media => {
  139. const focusX = media.getIn(['meta', 'focus', 'x']);
  140. const focusY = media.getIn(['meta', 'focus', 'y']);
  141. const description = media.get('description') || '';
  142. if (focusX && focusY) {
  143. const x = (focusX / 2) + .5;
  144. const y = (focusY / -2) + .5;
  145. this.setState({
  146. x,
  147. y,
  148. focusX,
  149. focusY,
  150. description,
  151. dirty: false,
  152. });
  153. } else {
  154. this.setState({
  155. x: 0.5,
  156. y: 0.5,
  157. focusX: 0,
  158. focusY: 0,
  159. description,
  160. dirty: false,
  161. });
  162. }
  163. }
  164. handleChange = e => {
  165. this.setState({ description: e.target.value, dirty: true });
  166. }
  167. handleKeyDown = (e) => {
  168. if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
  169. e.preventDefault();
  170. e.stopPropagation();
  171. this.setState({ description: e.target.value, dirty: true });
  172. this.handleSubmit();
  173. }
  174. }
  175. handleSubmit = () => {
  176. this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
  177. this.props.onClose();
  178. }
  179. setRef = c => {
  180. this.node = c;
  181. }
  182. handleTextDetection = () => {
  183. const { media } = this.props;
  184. this.setState({ detecting: true });
  185. fetchTesseract().then(({ createWorker }) => {
  186. const worker = createWorker({
  187. workerPath: tesseractWorkerPath,
  188. corePath: tesseractCorePath,
  189. langPath: assetHost,
  190. logger: ({ status, progress }) => {
  191. if (status === 'recognizing text') {
  192. this.setState({ ocrStatus: 'detecting', progress });
  193. } else {
  194. this.setState({ ocrStatus: 'preparing', progress });
  195. }
  196. },
  197. });
  198. let media_url = media.get('url');
  199. if (window.URL && URL.createObjectURL) {
  200. try {
  201. media_url = URL.createObjectURL(media.get('file'));
  202. } catch (error) {
  203. console.error(error);
  204. }
  205. }
  206. (async () => {
  207. await worker.load();
  208. await worker.loadLanguage('eng');
  209. await worker.initialize('eng');
  210. const { data: { text } } = await worker.recognize(media_url);
  211. this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false });
  212. await worker.terminate();
  213. })();
  214. }).catch((e) => {
  215. console.error(e);
  216. this.setState({ detecting: false });
  217. });
  218. }
  219. handleThumbnailChange = e => {
  220. if (e.target.files.length > 0) {
  221. this.setState({ dirty: true });
  222. this.props.onSelectThumbnail(e.target.files);
  223. }
  224. }
  225. setFileInputRef = c => {
  226. this.fileInput = c;
  227. }
  228. handleFileInputClick = () => {
  229. this.fileInput.click();
  230. }
  231. render () {
  232. const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
  233. const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state;
  234. const width = media.getIn(['meta', 'original', 'width']) || null;
  235. const height = media.getIn(['meta', 'original', 'height']) || null;
  236. const focals = ['image', 'gifv'].includes(media.get('type'));
  237. const thumbnailable = ['audio', 'video'].includes(media.get('type'));
  238. const previewRatio = 16/9;
  239. const previewWidth = 200;
  240. const previewHeight = previewWidth / previewRatio;
  241. let descriptionLabel = null;
  242. if (media.get('type') === 'audio') {
  243. descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />;
  244. } else if (media.get('type') === 'video') {
  245. descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />;
  246. } else {
  247. descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />;
  248. }
  249. let ocrMessage = '';
  250. if (ocrStatus === 'detecting') {
  251. ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />;
  252. } else {
  253. ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />;
  254. }
  255. return (
  256. <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
  257. <div className='report-modal__target'>
  258. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
  259. <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
  260. </div>
  261. <div className='report-modal__container'>
  262. <div className='report-modal__comment'>
  263. {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>}
  264. {thumbnailable && (
  265. <React.Fragment>
  266. <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
  267. <Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
  268. <label>
  269. <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
  270. <input
  271. id='upload-modal__thumbnail'
  272. ref={this.setFileInputRef}
  273. type='file'
  274. accept='image/png,image/jpeg'
  275. onChange={this.handleThumbnailChange}
  276. style={{ display: 'none' }}
  277. disabled={isUploadingThumbnail}
  278. />
  279. </label>
  280. <hr className='setting-divider' />
  281. </React.Fragment>
  282. )}
  283. <label className='setting-text-label' htmlFor='upload-modal__description'>
  284. {descriptionLabel}
  285. </label>
  286. <div className='setting-text__wrapper'>
  287. <Textarea
  288. id='upload-modal__description'
  289. className='setting-text light'
  290. value={detecting ? '…' : description}
  291. onChange={this.handleChange}
  292. onKeyDown={this.handleKeyDown}
  293. disabled={detecting}
  294. autoFocus
  295. />
  296. <div className='setting-text__modifiers'>
  297. <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} />
  298. </div>
  299. </div>
  300. <div className='setting-text__toolbar'>
  301. <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>
  302. <CharacterCounter max={1500} text={detecting ? '' : description} />
  303. </div>
  304. <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
  305. </div>
  306. <div className='focal-point-modal__content'>
  307. {focals && (
  308. <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
  309. {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
  310. {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
  311. <div className='focal-point__preview'>
  312. <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
  313. <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
  314. </div>
  315. <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
  316. <div className='focal-point__overlay' />
  317. </div>
  318. )}
  319. {media.get('type') === 'video' && (
  320. <Video
  321. preview={media.get('preview_url')}
  322. frameRate={media.getIn(['meta', 'original', 'frame_rate'])}
  323. blurhash={media.get('blurhash')}
  324. src={media.get('url')}
  325. detailed
  326. inline
  327. editable
  328. />
  329. )}
  330. {media.get('type') === 'audio' && (
  331. <Audio
  332. src={media.get('url')}
  333. duration={media.getIn(['meta', 'original', 'duration'], 0)}
  334. height={150}
  335. poster={media.get('preview_url') || account.get('avatar_static')}
  336. backgroundColor={media.getIn(['meta', 'colors', 'background'])}
  337. foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
  338. accentColor={media.getIn(['meta', 'colors', 'accent'])}
  339. editable
  340. />
  341. )}
  342. </div>
  343. </div>
  344. </div>
  345. );
  346. }
  347. }