Co-authored-by: tobi <tobi.smethurst@protonmail.com> Reviewed-on: https://codeberg.org/superseriousbusiness/masto-fe-standalone/pulls/88 Co-authored-by: Zoë Bijl <moiety@noreply.codeberg.org> Co-committed-by: Zoë Bijl <moiety@noreply.codeberg.org>
437 lines
15 KiB
JavaScript
437 lines
15 KiB
JavaScript
import PropTypes from "prop-types";
|
|
import { PureComponent } from "react";
|
|
|
|
import { FormattedMessage, defineMessages, injectIntl } from "react-intl";
|
|
|
|
import classNames from "classnames";
|
|
|
|
import ImmutablePropTypes from "react-immutable-proptypes";
|
|
import ImmutablePureComponent from "react-immutable-pure-component";
|
|
import { connect } from "react-redux";
|
|
|
|
import Textarea from "react-textarea-autosize";
|
|
import { length } from "stringz";
|
|
import tesseractWorkerPath from "tesseract.js/dist/worker.min.js";
|
|
|
|
import tesseractCorePath from "tesseract.js-core/tesseract-core.wasm.js";
|
|
|
|
import Button from "mastodon/components/button";
|
|
import { GIFV } from "mastodon/components/gifv";
|
|
import { IconButton } from "mastodon/components/icon_button";
|
|
import Audio from "mastodon/features/audio";
|
|
import CharacterCounter from "mastodon/features/compose/components/character_counter";
|
|
import UploadProgress from "mastodon/features/compose/components/upload_progress";
|
|
import { Tesseract as fetchTesseract } from "mastodon/features/ui/util/async-components";
|
|
import { me , maxMediaDescChars } from "mastodon/initial_state";
|
|
import { assetHost } from "mastodon/utils/config";
|
|
|
|
import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from "../../../actions/compose";
|
|
import Video, { getPointerPosition } from "../../video";
|
|
|
|
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 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;
|
|
|
|
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 && (
|
|
<>
|
|
<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' />
|
|
</>
|
|
)}
|
|
|
|
<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={maxMediaDescChars} text={detecting ? "" : description} />
|
|
</div>
|
|
|
|
<Button
|
|
type='submit'
|
|
disabled={!dirty || detecting || isUploadingThumbnail || length(description) > maxMediaDescChars || 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")} key={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, { forwardRef: true }));
|