import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import classNames from 'classnames'; import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose'; import { getPointerPosition } from '../../video'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import IconButton from 'mastodon/components/icon_button'; import Button from 'mastodon/components/button'; import Video from 'mastodon/features/video'; import Audio from 'mastodon/features/audio'; import Textarea from 'react-textarea-autosize'; import UploadProgress from 'mastodon/features/compose/components/upload_progress'; import CharacterCounter from 'mastodon/features/compose/components/character_counter'; import { length } from 'stringz'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; import GIFV from 'mastodon/components/gifv'; import { me } from 'mastodon/initial_state'; // eslint-disable-next-line import/no-extraneous-dependencies import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; // eslint-disable-next-line import/extensions import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' }, placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' }, discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' }, discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' }, }); const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), account: state.getIn(['accounts', me]), isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']), description: state.getIn(['compose', 'media_modal', 'description']), lang: state.getIn(['compose', 'language']), focusX: state.getIn(['compose', 'media_modal', 'focusX']), focusY: state.getIn(['compose', 'media_modal', 'focusY']), dirty: state.getIn(['compose', 'media_modal', 'dirty']), is_changing_upload: state.getIn(['compose', 'is_changing_upload']), }); const mapDispatchToProps = (dispatch, { id }) => ({ onSave: (description, x, y) => { dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); }, onChangeDescription: (description) => { dispatch(onChangeMediaDescription(description)); }, onChangeFocus: (focusX, focusY) => { dispatch(onChangeMediaFocus(focusX, focusY)); }, onSelectThumbnail: files => { dispatch(uploadThumbnail(id, files[0])); }, }); const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); class ImageLoader extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, width: PropTypes.number, height: PropTypes.number, }; state = { loading: true, }; componentDidMount() { const image = new Image(); image.addEventListener('load', () => this.setState({ loading: false })); image.src = this.props.src; } render () { const { loading } = this.state; if (loading) { return <canvas width={this.props.width} height={this.props.height} />; } else { return <img {...this.props} alt='' />; } } } class FocalPointModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired, isUploadingThumbnail: PropTypes.bool, onSave: PropTypes.func.isRequired, onChangeDescription: PropTypes.func.isRequired, onChangeFocus: PropTypes.func.isRequired, onSelectThumbnail: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; state = { dragging: false, dirty: false, progress: 0, loading: true, ocrStatus: '', }; componentWillUnmount () { document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); } handleMouseDown = e => { document.addEventListener('mousemove', this.handleMouseMove); document.addEventListener('mouseup', this.handleMouseUp); this.updatePosition(e); this.setState({ dragging: true }); }; handleTouchStart = e => { document.addEventListener('touchmove', this.handleMouseMove); document.addEventListener('touchend', this.handleTouchEnd); this.updatePosition(e); this.setState({ dragging: true }); }; handleMouseMove = e => { this.updatePosition(e); }; handleMouseUp = () => { document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); this.setState({ dragging: false }); }; handleTouchEnd = () => { document.removeEventListener('touchmove', this.handleMouseMove); document.removeEventListener('touchend', this.handleTouchEnd); this.setState({ dragging: false }); }; updatePosition = e => { const { x, y } = getPointerPosition(this.node, e); const focusX = (x - .5) * 2; const focusY = (y - .5) * -2; this.props.onChangeFocus(focusX, focusY); }; handleChange = e => { this.props.onChangeDescription(e.target.value); }; handleKeyDown = (e) => { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { this.props.onChangeDescription(e.target.value); this.handleSubmit(e); } }; handleSubmit = (e) => { e.preventDefault(); e.stopPropagation(); this.props.onSave(this.props.description, this.props.focusX, this.props.focusY); }; getCloseConfirmationMessage = () => { const { intl, dirty } = this.props; if (dirty) { return { message: intl.formatMessage(messages.discardMessage), confirm: intl.formatMessage(messages.discardConfirm), }; } else { return null; } }; setRef = c => { this.node = c; }; handleTextDetection = () => { this._detectText(); }; _detectText = (refreshCache = false) => { const { media } = this.props; this.setState({ detecting: true }); fetchTesseract().then(({ createWorker }) => { const worker = createWorker({ workerPath: tesseractWorkerPath, corePath: tesseractCorePath, langPath: `${assetHost}/ocr/lang-data/`, logger: ({ status, progress }) => { if (status === 'recognizing text') { this.setState({ ocrStatus: 'detecting', progress }); } else { this.setState({ ocrStatus: 'preparing', progress }); } }, cacheMethod: refreshCache ? 'refresh' : 'write', }); let media_url = media.get('url'); if (window.URL && URL.createObjectURL) { try { media_url = URL.createObjectURL(media.get('file')); } catch (error) { console.error(error); } } return (async () => { await worker.load(); await worker.loadLanguage('eng'); await worker.initialize('eng'); const { data: { text } } = await worker.recognize(media_url); this.setState({ detecting: false }); this.props.onChangeDescription(removeExtraLineBreaks(text)); await worker.terminate(); })().catch((e) => { if (refreshCache) { throw e; } else { this._detectText(true); } }); }).catch((e) => { console.error(e); this.setState({ detecting: false }); }); }; handleThumbnailChange = e => { if (e.target.files.length > 0) { this.props.onSelectThumbnail(e.target.files); } }; setFileInputRef = c => { this.fileInput = c; }; handleFileInputClick = () => { this.fileInput.click(); }; render () { const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props; const { dragging, detecting, progress, ocrStatus } = this.state; const x = (focusX / 2) + .5; const y = (focusY / -2) + .5; const width = media.getIn(['meta', 'original', 'width']) || null; const height = media.getIn(['meta', 'original', 'height']) || null; const focals = ['image', 'gifv'].includes(media.get('type')); const thumbnailable = ['audio', 'video'].includes(media.get('type')); const previewRatio = 16/9; const previewWidth = 200; const previewHeight = previewWidth / previewRatio; let descriptionLabel = null; if (media.get('type') === 'audio') { descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />; } else if (media.get('type') === 'video') { descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />; } else { descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />; } let ocrMessage = ''; if (ocrStatus === 'detecting') { ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />; } else { ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />; } return ( <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> <div className='report-modal__target'> <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} /> <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> </div> <div className='report-modal__container'> <form className='report-modal__comment' onSubmit={this.handleSubmit} > {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>} {thumbnailable && ( <React.Fragment> <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label> <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} /> <label> <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span> <input id='upload-modal__thumbnail' ref={this.setFileInputRef} type='file' accept='image/png,image/jpeg' onChange={this.handleThumbnailChange} style={{ display: 'none' }} disabled={isUploadingThumbnail || is_changing_upload} /> </label> <hr className='setting-divider' /> </React.Fragment> )} <label className='setting-text-label' htmlFor='upload-modal__description'> {descriptionLabel} </label> <div className='setting-text__wrapper'> <Textarea id='upload-modal__description' className='setting-text light' value={detecting ? '…' : description} lang={lang} onChange={this.handleChange} onKeyDown={this.handleKeyDown} disabled={detecting || is_changing_upload} autoFocus /> <div className='setting-text__modifiers'> <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} /> </div> </div> <div className='setting-text__toolbar'> <button type='button' disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection} > <FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /> </button> <CharacterCounter max={1500} text={detecting ? '' : description} /> </div> <Button type='submit' disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} /> </form> <div className='focal-point-modal__content'> {focals && ( <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} <div className='focal-point__preview'> <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> </div> <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> <div className='focal-point__overlay' /> </div> )} {media.get('type') === 'video' && ( <Video preview={media.get('preview_url')} frameRate={media.getIn(['meta', 'original', 'frame_rate'])} blurhash={media.get('blurhash')} src={media.get('url')} detailed inline editable /> )} {media.get('type') === 'audio' && ( <Audio src={media.get('url')} duration={media.getIn(['meta', 'original', 'duration'], 0)} height={150} poster={media.get('preview_url') || account.get('avatar_static')} backgroundColor={media.getIn(['meta', 'colors', 'background'])} foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} accentColor={media.getIn(['meta', 'colors', 'accent'])} editable /> )} </div> </div> </div> ); } } export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true, })(injectIntl(FocalPointModal, { withRef: true }));