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.

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