The code powering m.abunchtell.com https://m.abunchtell.com
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.
 
 
 
 

317 line
10 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. handleSubmit = () => {
  152. this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
  153. this.props.onClose();
  154. }
  155. setRef = c => {
  156. this.node = c;
  157. }
  158. handleTextDetection = () => {
  159. const { media } = this.props;
  160. this.setState({ detecting: true });
  161. fetchTesseract().then(({ TesseractWorker }) => {
  162. const worker = new TesseractWorker({
  163. workerPath: `${assetHost}/packs/ocr/worker.min.js`,
  164. corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
  165. langPath: `${assetHost}/ocr/lang-data`,
  166. });
  167. let media_url = media.get('file');
  168. if (window.URL && URL.createObjectURL) {
  169. try {
  170. media_url = URL.createObjectURL(media.get('file'));
  171. } catch (error) {
  172. console.error(error);
  173. }
  174. }
  175. worker.recognize(media_url)
  176. .progress(({ progress }) => this.setState({ progress }))
  177. .finally(() => worker.terminate())
  178. .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
  179. .catch(() => this.setState({ detecting: false }));
  180. }).catch(() => this.setState({ detecting: false }));
  181. }
  182. render () {
  183. const { media, intl, onClose } = this.props;
  184. const { x, y, dragging, description, dirty, detecting, progress } = this.state;
  185. const width = media.getIn(['meta', 'original', 'width']) || null;
  186. const height = media.getIn(['meta', 'original', 'height']) || null;
  187. const focals = ['image', 'gifv'].includes(media.get('type'));
  188. const previewRatio = 16/9;
  189. const previewWidth = 200;
  190. const previewHeight = previewWidth / previewRatio;
  191. return (
  192. <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
  193. <div className='report-modal__target'>
  194. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
  195. <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
  196. </div>
  197. <div className='report-modal__container'>
  198. <div className='report-modal__comment'>
  199. {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>}
  200. <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>
  201. <div className='setting-text__wrapper'>
  202. <Textarea
  203. id='upload-modal__description'
  204. className='setting-text light'
  205. value={detecting ? '…' : description}
  206. onChange={this.handleChange}
  207. disabled={detecting}
  208. autoFocus
  209. />
  210. <div className='setting-text__modifiers'>
  211. <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
  212. </div>
  213. </div>
  214. <div className='setting-text__toolbar'>
  215. <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>
  216. <CharacterCounter max={1500} text={detecting ? '' : description} />
  217. </div>
  218. <Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
  219. </div>
  220. <div className='focal-point-modal__content'>
  221. {focals && (
  222. <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
  223. {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
  224. {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
  225. <div className='focal-point__preview'>
  226. <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
  227. <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
  228. </div>
  229. <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
  230. <div className='focal-point__overlay' />
  231. </div>
  232. )}
  233. {media.get('type') === 'video' && (
  234. <Video
  235. preview={media.get('preview_url')}
  236. blurhash={media.get('blurhash')}
  237. src={media.get('url')}
  238. detailed
  239. inline
  240. editable
  241. />
  242. )}
  243. {media.get('type') === 'audio' && (
  244. <Audio
  245. src={media.get('url')}
  246. duration={media.getIn(['meta', 'original', 'duration'], 0)}
  247. height={150}
  248. preload
  249. editable
  250. />
  251. )}
  252. </div>
  253. </div>
  254. </div>
  255. );
  256. }
  257. }