闭社主体 forked from https://github.com/tootsuite/mastodon
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.

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