2023-05-24 01:15:17 +10:00
import PropTypes from 'prop-types' ;
2023-05-23 19:47:36 +10:00
import { PureComponent } from 'react' ;
2023-05-24 01:15:17 +10:00
import { FormattedMessage , defineMessages , injectIntl } from 'react-intl' ;
import classNames from 'classnames' ;
2018-02-22 10:35:46 +11:00
import ImmutablePropTypes from 'react-immutable-proptypes' ;
import ImmutablePureComponent from 'react-immutable-pure-component' ;
import { connect } from 'react-redux' ;
2023-05-24 01:15:17 +10:00
import Textarea from 'react-textarea-autosize' ;
import { length } from 'stringz' ;
// eslint-disable-next-line import/extensions
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js' ;
// eslint-disable-next-line import/no-extraneous-dependencies
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js' ;
2019-08-14 12:07:32 +10:00
import Button from 'mastodon/components/button' ;
2023-05-24 01:15:17 +10:00
import { GIFV } from 'mastodon/components/gifv' ;
import { IconButton } from 'mastodon/components/icon_button' ;
2019-08-24 06:38:02 +10:00
import Audio from 'mastodon/features/audio' ;
2019-08-15 23:13:26 +10:00
import CharacterCounter from 'mastodon/features/compose/components/character_counter' ;
2023-05-24 01:15:17 +10:00
import UploadProgress from 'mastodon/features/compose/components/upload_progress' ;
2019-08-16 01:24:45 +10:00
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components' ;
2020-07-03 00:27:35 +10:00
import { me } from 'mastodon/initial_state' ;
2020-10-13 10:19:35 +11:00
import { assetHost } from 'mastodon/utils/config' ;
2019-08-14 12:07:32 +10:00
2023-05-24 01:15:17 +10:00
import { changeUploadCompose , uploadThumbnail , onChangeMediaDescription , onChangeMediaFocus } from '../../../actions/compose' ;
import Video , { getPointerPosition } from '../../video' ;
2019-08-14 12:07:32 +10:00
const messages = defineMessages ( {
close : { id : 'lightbox.close' , defaultMessage : 'Close' } ,
apply : { id : 'upload_modal.apply' , defaultMessage : 'Apply' } ,
2021-07-25 09:14:43 +10:00
applying : { id : 'upload_modal.applying' , defaultMessage : 'Applying…' } ,
2019-08-14 12:07:32 +10:00
placeholder : { id : 'upload_modal.description_placeholder' , defaultMessage : 'A quick brown fox jumps over the lazy dog' } ,
2020-07-07 20:14:19 +10:00
chooseImage : { id : 'upload_modal.choose_image' , defaultMessage : 'Choose image' } ,
2021-07-25 09:14:43 +10:00
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' } ,
2019-08-14 12:07:32 +10:00
} ) ;
2018-02-22 10:35:46 +11:00
const mapStateToProps = ( state , { id } ) => ( {
media : state . getIn ( [ 'compose' , 'media_attachments' ] ) . find ( item => item . get ( 'id' ) === id ) ,
2020-07-03 00:27:35 +10:00
account : state . getIn ( [ 'accounts' , me ] ) ,
2020-07-07 20:14:19 +10:00
isUploadingThumbnail : state . getIn ( [ 'compose' , 'isUploadingThumbnail' ] ) ,
2021-07-25 09:14:43 +10:00
description : state . getIn ( [ 'compose' , 'media_modal' , 'description' ] ) ,
2023-01-30 05:00:19 +11:00
lang : state . getIn ( [ 'compose' , 'language' ] ) ,
2021-07-25 09:14:43 +10:00
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' ] ) ,
2018-02-22 10:35:46 +11:00
} ) ;
const mapDispatchToProps = ( dispatch , { id } ) => ( {
2019-08-14 12:07:32 +10:00
onSave : ( description , x , y ) => {
dispatch ( changeUploadCompose ( id , { description , focus : ` ${ x . toFixed ( 2 ) } , ${ y . toFixed ( 2 ) } ` } ) ) ;
2018-02-22 10:35:46 +11:00
} ,
2021-07-25 09:14:43 +10:00
onChangeDescription : ( description ) => {
dispatch ( onChangeMediaDescription ( description ) ) ;
} ,
onChangeFocus : ( focusX , focusY ) => {
dispatch ( onChangeMediaFocus ( focusX , focusY ) ) ;
} ,
2020-07-07 20:14:19 +10:00
onSelectThumbnail : files => {
dispatch ( uploadThumbnail ( id , files [ 0 ] ) ) ;
} ,
2018-02-22 10:35:46 +11:00
} ) ;
2019-08-15 23:13:26 +10:00
const removeExtraLineBreaks = str => str . replace ( /\n\n/g , '******' )
. replace ( /\n/g , ' ' )
. replace ( /\*\*\*\*\*\*/g , '\n\n' ) ;
2023-05-23 18:52:27 +10:00
class ImageLoader extends PureComponent {
2019-10-10 14:21:38 +11:00
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 = '' / > ;
}
}
}
2018-09-15 01:59:48 +10:00
class FocalPointModal extends ImmutablePureComponent {
2018-02-22 10:35:46 +11:00
static propTypes = {
media : ImmutablePropTypes . map . isRequired ,
2020-07-03 00:27:35 +10:00
account : ImmutablePropTypes . map . isRequired ,
2020-07-07 20:14:19 +10:00
isUploadingThumbnail : PropTypes . bool ,
onSave : PropTypes . func . isRequired ,
2021-07-25 09:14:43 +10:00
onChangeDescription : PropTypes . func . isRequired ,
onChangeFocus : PropTypes . func . isRequired ,
2020-07-07 20:14:19 +10:00
onSelectThumbnail : PropTypes . func . isRequired ,
2019-08-14 12:07:32 +10:00
onClose : PropTypes . func . isRequired ,
intl : PropTypes . object . isRequired ,
2018-02-22 10:35:46 +11:00
} ;
state = {
dragging : false ,
2019-08-14 12:07:32 +10:00
dirty : false ,
2019-08-15 23:13:26 +10:00
progress : 0 ,
2019-10-10 14:21:38 +11:00
loading : true ,
2020-09-01 08:26:10 +10:00
ocrStatus : '' ,
2018-02-22 10:35:46 +11:00
} ;
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 } ) ;
2023-01-30 11:45:35 +11:00
} ;
2018-02-22 10:35:46 +11:00
2019-08-16 04:28:56 +10:00
handleTouchStart = e => {
document . addEventListener ( 'touchmove' , this . handleMouseMove ) ;
document . addEventListener ( 'touchend' , this . handleTouchEnd ) ;
this . updatePosition ( e ) ;
this . setState ( { dragging : true } ) ;
2023-01-30 11:45:35 +11:00
} ;
2019-08-16 04:28:56 +10:00
2018-02-22 10:35:46 +11:00
handleMouseMove = e => {
this . updatePosition ( e ) ;
2023-01-30 11:45:35 +11:00
} ;
2018-02-22 10:35:46 +11:00
handleMouseUp = ( ) => {
document . removeEventListener ( 'mousemove' , this . handleMouseMove ) ;
document . removeEventListener ( 'mouseup' , this . handleMouseUp ) ;
this . setState ( { dragging : false } ) ;
2023-01-30 11:45:35 +11:00
} ;
2018-02-22 10:35:46 +11:00
2019-08-16 04:28:56 +10:00
handleTouchEnd = ( ) => {
document . removeEventListener ( 'touchmove' , this . handleMouseMove ) ;
document . removeEventListener ( 'touchend' , this . handleTouchEnd ) ;
this . setState ( { dragging : false } ) ;
2023-01-30 11:45:35 +11:00
} ;
2019-08-16 04:28:56 +10:00
2018-02-22 10:35:46 +11:00
updatePosition = e => {
const { x , y } = getPointerPosition ( this . node , e ) ;
const focusX = ( x - .5 ) * 2 ;
const focusY = ( y - .5 ) * - 2 ;
2021-07-25 09:14:43 +10:00
this . props . onChangeFocus ( focusX , focusY ) ;
2023-01-30 11:45:35 +11:00
} ;
2018-02-22 10:35:46 +11:00
2019-08-14 12:07:32 +10:00
handleChange = e => {
2021-07-25 09:14:43 +10:00
this . props . onChangeDescription ( e . target . value ) ;
2023-01-30 11:45:35 +11:00
} ;
2019-08-14 12:07:32 +10:00
2019-11-04 22:59:17 +11:00
handleKeyDown = ( e ) => {
if ( e . keyCode === 13 && ( e . ctrlKey || e . metaKey ) ) {
2021-07-25 09:14:43 +10:00
this . props . onChangeDescription ( e . target . value ) ;
2022-11-08 01:41:42 +11:00
this . handleSubmit ( e ) ;
2019-11-04 22:59:17 +11:00
}
2023-01-30 11:45:35 +11:00
} ;
2019-11-04 22:59:17 +11:00
2022-11-08 01:41:42 +11:00
handleSubmit = ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2021-07-25 09:14:43 +10:00
this . props . onSave ( this . props . description , this . props . focusX , this . props . focusY ) ;
2023-01-30 11:45:35 +11:00
} ;
2021-07-25 09:14:43 +10:00
getCloseConfirmationMessage = ( ) => {
const { intl , dirty } = this . props ;
if ( dirty ) {
return {
message : intl . formatMessage ( messages . discardMessage ) ,
confirm : intl . formatMessage ( messages . discardConfirm ) ,
} ;
} else {
return null ;
}
2023-01-30 11:45:35 +11:00
} ;
2019-08-14 12:07:32 +10:00
2018-02-22 10:35:46 +11:00
setRef = c => {
this . node = c ;
2023-01-30 11:45:35 +11:00
} ;
2018-02-22 10:35:46 +11:00
2019-08-15 23:13:26 +10:00
handleTextDetection = ( ) => {
2021-06-16 06:11:07 +10:00
this . _detectText ( ) ;
2023-01-30 11:45:35 +11:00
} ;
2021-06-16 06:11:07 +10:00
_detectText = ( refreshCache = false ) => {
2019-08-15 23:13:26 +10:00
const { media } = this . props ;
this . setState ( { detecting : true } ) ;
2020-09-01 08:26:10 +10:00
fetchTesseract ( ) . then ( ( { createWorker } ) => {
const worker = createWorker ( {
workerPath : tesseractWorkerPath ,
corePath : tesseractCorePath ,
2021-01-09 13:48:50 +11:00
langPath : ` ${ assetHost } /ocr/lang-data/ ` ,
2020-09-01 08:26:10 +10:00
logger : ( { status , progress } ) => {
if ( status === 'recognizing text' ) {
this . setState ( { ocrStatus : 'detecting' , progress } ) ;
} else {
this . setState ( { ocrStatus : 'preparing' , progress } ) ;
}
} ,
2021-06-16 06:11:07 +10:00
cacheMethod : refreshCache ? 'refresh' : 'write' ,
2019-08-16 01:24:45 +10:00
} ) ;
2019-11-25 11:42:51 +11:00
let media _url = media . get ( 'url' ) ;
2019-09-27 10:16:11 +10:00
if ( window . URL && URL . createObjectURL ) {
try {
media _url = URL . createObjectURL ( media . get ( 'file' ) ) ;
} catch ( error ) {
console . error ( error ) ;
}
}
2021-06-16 06:11:07 +10:00
return ( async ( ) => {
2020-09-01 08:26:10 +10:00
await worker . load ( ) ;
await worker . loadLanguage ( 'eng' ) ;
await worker . initialize ( 'eng' ) ;
const { data : { text } } = await worker . recognize ( media _url ) ;
2021-07-25 09:14:43 +10:00
this . setState ( { detecting : false } ) ;
this . props . onChangeDescription ( removeExtraLineBreaks ( text ) ) ;
2020-09-01 08:26:10 +10:00
await worker . terminate ( ) ;
2021-06-16 06:11:07 +10:00
} ) ( ) . catch ( ( e ) => {
if ( refreshCache ) {
throw e ;
} else {
this . _detectText ( true ) ;
}
} ) ;
2020-09-01 08:26:10 +10:00
} ) . catch ( ( e ) => {
console . error ( e ) ;
this . setState ( { detecting : false } ) ;
} ) ;
2023-01-30 11:45:35 +11:00
} ;
2019-08-15 23:13:26 +10:00
2020-07-07 20:14:19 +10:00
handleThumbnailChange = e => {
if ( e . target . files . length > 0 ) {
this . props . onSelectThumbnail ( e . target . files ) ;
}
2023-01-30 11:45:35 +11:00
} ;
2020-07-07 20:14:19 +10:00
setFileInputRef = c => {
this . fileInput = c ;
2023-01-30 11:45:35 +11:00
} ;
2020-07-07 20:14:19 +10:00
handleFileInputClick = ( ) => {
this . fileInput . click ( ) ;
2023-01-30 11:45:35 +11:00
} ;
2020-07-07 20:14:19 +10:00
2018-02-22 10:35:46 +11:00
render ( ) {
2023-01-30 05:00:19 +11:00
const { media , intl , account , onClose , isUploadingThumbnail , description , lang , focusX , focusY , dirty , is _changing _upload } = this . props ;
2021-07-25 09:14:43 +10:00
const { dragging , detecting , progress , ocrStatus } = this . state ;
const x = ( focusX / 2 ) + .5 ;
const y = ( focusY / - 2 ) + .5 ;
2018-02-22 10:35:46 +11:00
const width = media . getIn ( [ 'meta' , 'original' , 'width' ] ) || null ;
const height = media . getIn ( [ 'meta' , 'original' , 'height' ] ) || null ;
2019-08-14 12:07:32 +10:00
const focals = [ 'image' , 'gifv' ] . includes ( media . get ( 'type' ) ) ;
2020-07-07 20:14:19 +10:00
const thumbnailable = [ 'audio' , 'video' ] . includes ( media . get ( 'type' ) ) ;
2019-08-14 12:07:32 +10:00
const previewRatio = 16 / 9 ;
const previewWidth = 200 ;
const previewHeight = previewWidth / previewRatio ;
2018-02-22 10:35:46 +11:00
2019-11-21 21:39:07 +11:00
let descriptionLabel = null ;
if ( media . get ( 'type' ) === 'audio' ) {
2022-12-16 04:46:13 +11:00
descriptionLabel = < FormattedMessage id = 'upload_form.audio_description' defaultMessage = 'Describe for people who are hard of hearing' / > ;
2019-11-21 21:39:07 +11:00
} else if ( media . get ( 'type' ) === 'video' ) {
2022-12-16 04:46:13 +11:00
descriptionLabel = < FormattedMessage id = 'upload_form.video_description' defaultMessage = 'Describe for people who are deaf, hard of hearing, blind or have low vision' / > ;
2019-11-21 21:39:07 +11:00
} else {
2022-12-16 04:46:13 +11:00
descriptionLabel = < FormattedMessage id = 'upload_form.description' defaultMessage = 'Describe for people who are blind or have low vision' / > ;
2019-11-21 21:39:07 +11:00
}
2020-09-01 08:26:10 +10:00
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…' / > ;
}
2018-02-22 10:35:46 +11:00
return (
2019-08-14 12:07:32 +10:00
< div className = 'modal-root__modal report-modal' style = { { maxWidth : 960 } } >
< div className = 'report-modal__target' >
2021-05-12 05:15:57 +10:00
< IconButton className = 'report-modal__close' title = { intl . formatMessage ( messages . close ) } icon = 'times' onClick = { onClose } size = { 20 } / >
2019-08-14 12:07:32 +10:00
< FormattedMessage id = 'upload_modal.edit_media' defaultMessage = 'Edit media' / >
< / div >
< div className = 'report-modal__container' >
2022-11-08 01:41:42 +11:00
< form className = 'report-modal__comment' onSubmit = { this . handleSubmit } >
2019-08-14 12:07:32 +10:00
{ 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 > }
2020-07-07 20:14:19 +10:00
{ thumbnailable && (
2023-05-23 19:47:36 +10:00
< >
2020-07-07 20:14:19 +10:00
< label className = 'setting-text-label' htmlFor = 'upload-modal__thumbnail' > < FormattedMessage id = 'upload_form.thumbnail' defaultMessage = 'Change thumbnail' / > < / label >
2023-01-19 02:33:55 +11:00
< Button disabled = { isUploadingThumbnail || ! media . get ( 'unattached' ) } text = { intl . formatMessage ( messages . chooseImage ) } onClick = { this . handleFileInputClick } / >
2020-07-07 20:14:19 +10:00
< 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' } }
2021-07-25 09:14:43 +10:00
disabled = { isUploadingThumbnail || is _changing _upload }
2020-07-07 20:14:19 +10:00
/ >
< / label >
< hr className = 'setting-divider' / >
2023-05-23 19:47:36 +10:00
< / >
2020-07-07 20:14:19 +10:00
) }
2019-11-21 21:39:07 +11:00
< label className = 'setting-text-label' htmlFor = 'upload-modal__description' >
{ descriptionLabel }
< / label >
2019-08-14 12:07:32 +10:00
2019-08-15 23:13:26 +10:00
< div className = 'setting-text__wrapper' >
< Textarea
id = 'upload-modal__description'
className = 'setting-text light'
value = { detecting ? '…' : description }
2023-01-30 05:00:19 +11:00
lang = { lang }
2019-08-15 23:13:26 +10:00
onChange = { this . handleChange }
2019-11-04 22:59:17 +11:00
onKeyDown = { this . handleKeyDown }
2021-07-25 09:14:43 +10:00
disabled = { detecting || is _changing _upload }
2019-08-15 23:13:26 +10:00
autoFocus
/ >
< div className = 'setting-text__modifiers' >
2020-09-01 08:26:10 +10:00
< UploadProgress progress = { progress * 100 } active = { detecting } icon = 'file-text-o' message = { ocrMessage } / >
2019-08-15 23:13:26 +10:00
< / div >
< / div >
< div className = 'setting-text__toolbar' >
2022-11-08 01:41:42 +11:00
< 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 >
2019-09-14 00:00:34 +10:00
< CharacterCounter max = { 1500 } text = { detecting ? '' : description } / >
2019-08-15 23:13:26 +10:00
< / div >
2022-11-08 01:41:42 +11:00
< Button
type = 'submit'
disabled = { ! dirty || detecting || isUploadingThumbnail || length ( description ) > 1500 || is _changing _upload }
text = { intl . formatMessage ( is _changing _upload ? messages . applying : messages . apply ) }
/ >
< / form >
2019-08-14 12:07:32 +10:00
2019-08-16 06:49:00 +10:00
< div className = 'focal-point-modal__content' >
2019-08-14 12:07:32 +10:00
{ focals && (
2019-08-16 06:47:51 +10:00
< div className = { classNames ( 'focal-point' , { dragging } ) } ref = { this . setRef } onMouseDown = { this . handleMouseDown } onTouchStart = { this . handleTouchStart } >
2019-10-10 14:21:38 +11:00
{ media . get ( 'type' ) === 'image' && < ImageLoader src = { media . get ( 'url' ) } width = { width } height = { height } alt = '' / > }
2023-04-17 00:09:04 +10:00
{ media . get ( 'type' ) === 'gifv' && < GIFV src = { media . get ( 'url' ) } key = { media . get ( 'url' ) } width = { width } height = { height } / > }
2019-08-14 12:07:32 +10:00
< 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 } % ` } } / >
2019-08-16 06:47:51 +10:00
< div className = 'focal-point__overlay' / >
2019-08-14 12:07:32 +10:00
< / div >
) }
2019-08-24 06:38:02 +10:00
{ media . get ( 'type' ) === 'video' && (
2019-08-14 12:07:32 +10:00
< Video
preview = { media . get ( 'preview_url' ) }
2020-11-22 09:19:04 +11:00
frameRate = { media . getIn ( [ 'meta' , 'original' , 'frame_rate' ] ) }
2019-08-14 12:07:32 +10:00
blurhash = { media . get ( 'blurhash' ) }
src = { media . get ( 'url' ) }
detailed
2019-08-24 06:38:02 +10:00
inline
editable
/ >
) }
{ media . get ( 'type' ) === 'audio' && (
< Audio
src = { media . get ( 'url' ) }
duration = { media . getIn ( [ 'meta' , 'original' , 'duration' ] , 0 ) }
height = { 150 }
2020-07-03 00:27:35 +10:00
poster = { media . get ( 'preview_url' ) || account . get ( 'avatar_static' ) }
2020-07-06 02:28:25 +10:00
backgroundColor = { media . getIn ( [ 'meta' , 'colors' , 'background' ] ) }
foregroundColor = { media . getIn ( [ 'meta' , 'colors' , 'foreground' ] ) }
accentColor = { media . getIn ( [ 'meta' , 'colors' , 'accent' ] ) }
2019-08-14 12:07:32 +10:00
editable
/ >
) }
< / div >
2018-02-22 10:35:46 +11:00
< / div >
< / div >
) ;
}
}
2023-03-24 13:17:53 +11:00
export default connect ( mapStateToProps , mapDispatchToProps , null , {
forwardRef : true ,
2023-08-04 23:48:29 +10:00
} ) ( injectIntl ( FocalPointModal , { forwardRef : true } ) ) ;