pull/33516/merge
Eugen Rochko 2025-01-08 18:12:01 +00:00 committed by GitHub
commit 613b9c0f58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 397 additions and 689 deletions

View File

@ -414,7 +414,7 @@ export function initMediaEditModal(id) {
dispatch(openModal({
modalType: 'FOCAL_POINT',
modalProps: { id },
modalProps: { mediaId: id },
}));
};
}

View File

@ -7,6 +7,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean;
secondary?: boolean;
compact?: boolean;
dangerous?: boolean;
}
@ -27,6 +28,7 @@ export const Button: React.FC<Props> = ({
disabled,
block,
secondary,
compact,
dangerous,
className,
title,
@ -47,6 +49,7 @@ export const Button: React.FC<Props> = ({
<button
className={classNames('button', className, {
'button-secondary': secondary,
'button--compact': compact,
'button--block': block,
'button--dangerous': dangerous,
})}

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useIdentity } from '@/mastodon/identity_context';
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
@ -20,7 +21,8 @@ const messages = defineMessages({
export const FollowButton: React.FC<{
accountId?: string;
}> = ({ accountId }) => {
compact?: boolean;
}> = ({ accountId, compact }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@ -89,7 +91,9 @@ export const FollowButton: React.FC<{
href='/settings/profile'
target='_blank'
rel='noopener'
className='button button-secondary'
className={classNames('button button-secondary', {
'button--compact': compact,
})}
>
{label}
</a>
@ -106,6 +110,7 @@ export const FollowButton: React.FC<{
(account?.suspended || !!account?.moved))
}
secondary={following}
compact={compact}
className={following ? 'button--destructive' : undefined}
>
{label}

View File

@ -2,11 +2,10 @@ import { useCallback, useState } from 'react';
interface Props {
src: string;
key: string;
alt?: string;
lang?: string;
width: number;
height: number;
width?: number;
height?: number;
onClick?: () => void;
}
@ -20,27 +19,22 @@ export const GIFV: React.FC<Props> = ({
}) => {
const [loading, setLoading] = useState(true);
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> =
useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleLoadedData = useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleClick: React.MouseEventHandler = useCallback(
(e) => {
if (onClick) {
e.stopPropagation();
onClick();
}
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
},
[onClick],
);
return (
<div className='gifv' style={{ position: 'relative' }}>
<div className='gifv'>
{loading && (
<canvas
width={width}
height={height}
role='button'
tabIndex={0}
aria-label={alt}
@ -57,13 +51,14 @@ export const GIFV: React.FC<Props> = ({
aria-label={alt}
title={alt}
lang={lang}
width={width}
height={height}
muted
loop
autoPlay
playsInline
onClick={handleClick}
onLoadedData={handleLoadedData}
style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
/>
</div>
);

View File

@ -0,0 +1,244 @@
import { useState, useCallback, useRef } from 'react';
import Textarea from 'react-textarea-autosize';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
// 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';
import { changeUploadCompose, uploadThumbnail } from 'mastodon/actions/compose';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { Button } from 'mastodon/components/button';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import { assetHost } from 'mastodon/utils/config';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Skeleton } from 'mastodon/components/skeleton';
import Audio from 'mastodon/features/audio';
import Video from 'mastodon/features/video';
import { GIFV } from 'mastodon/components/gifv';
const messages = defineMessages({
placeholderVisual: {
id: '',
defaultMessage: 'Describe this for people with visual impairments…',
},
placeholderHearing: {
id: '',
defaultMessage: 'Describe this for people with hearing impairments…',
},
});
const UploadButton: React.FC<{
children: React.ReactNode;
onSelectFile: (arg0?: File) => void;
}> = ({ children, onSelectFile }) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleClick = useCallback(() => {
fileRef.current?.click();
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onSelectFile(files[0]);
}
},
[onSelectFile],
);
return (
<label>
<Button onClick={handleClick}>{children}</Button>
<input
id='upload-modal__thumbnail'
ref={fileRef}
type='file'
accept='image/png,image/jpeg'
onChange={handleChange}
style={{ display: 'none' }}
/>
</label>
);
};
const Preview: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const media = useAppSelector((state) =>
state.compose.get('media_attachments').find((x) => x.get('id') === mediaId),
);
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
if (media.get('type') === 'image') {
return <img src={media.get('url')} alt='' />;
} else if (media.get('type') === 'gifv') {
return <GIFV src={media.get('url')} />;
} else if (media.get('type') === 'video') {
return (
<Video
preview={media.get('preview_url')}
frameRate={media.getIn(['meta', 'original', 'frame_rate'])}
blurhash={media.get('blurhash')}
src={media.get('url')}
detailed
inline
editable
/>
);
} else if (media.get('type') === 'audio') {
return (
<Audio
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
poster={media.get('preview_url') ?? account?.avatar_static}
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
accentColor={media.getIn(['meta', 'colors', 'accent'])}
editable
/>
);
} else {
return null;
}
};
export const AltTextModal: React.FC<{
mediaId: string;
onClose: () => void;
}> = ({ mediaId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const media = useAppSelector((state) =>
state.compose.get('media_attachments').find((x) => x.get('id') === mediaId),
);
const lang = useAppSelector((state) => state.compose.get('lang'));
const [description, setDescription] = useState(
media.get('description') ?? '',
);
const [isDetecting, setIsDetecting] = useState(false);
const type = media.get('type') as string;
const handleDescriptionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(e.target.value);
},
[setDescription],
);
const handleThumbnailChange = useCallback(
(file: File) => {
dispatch(uploadThumbnail(mediaId, file));
},
[dispatch, mediaId],
);
const handleSubmit = useCallback(() => {
dispatch(changeUploadCompose(mediaId, { description }));
onClose();
}, [dispatch, mediaId, onClose, description]);
const handleDetectClick = useCallback(() => {
setIsDetecting(true);
void fetchTesseract().then(async ({ createWorker }) => {
const worker = await createWorker('eng', 1, {
workerPath: tesseractWorkerPath,
corePath: tesseractCorePath,
langPath: `${assetHost}/ocr/lang-data`,
cacheMethod: 'write',
logger: (m) => console.log(m),
});
const image = URL.createObjectURL(media.get('file'));
const result = await worker.recognize(image);
setDescription(result.data.text);
setIsDetecting(false);
return '';
});
}, [setDescription, setIsDetecting, media]);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<Button onClick={handleSubmit}>
<FormattedMessage id='' defaultMessage='Done' />
</Button>
<span className='dialog-modal__header__title'>
<FormattedMessage id='' defaultMessage='Add alt text' />
</span>
<Button secondary onClick={onClose}>
<FormattedMessage id='' defaultMessage='Cancel' />
</Button>
</div>
<div className='dialog-modal__content'>
<div className='dialog-modal__content__preview'>
<Preview mediaId={mediaId} />
{(type === 'audio' || type === 'video') && (
<UploadButton onSelectFile={handleThumbnailChange}>
<FormattedMessage id='' defaultMessage='Change thumbnail' />
</UploadButton>
)}
</div>
<form
className='dialog-modal__content__form simple_form'
onSubmit={handleSubmit}
>
<div className='input'>
<div className='label_input'>
<Textarea
id='description'
value={isDetecting ? ' ' : description}
onChange={handleDescriptionChange}
lang={lang}
placeholder={intl.formatMessage(
type === 'audio'
? messages.placeholderHearing
: messages.placeholderVisual,
)}
minRows={3}
disabled={isDetecting}
/>
{isDetecting && (
<div className='label_input__loading-indicator'>
<Skeleton width='100%' />
<Skeleton width='100%' />
<Skeleton width='61%' />
</div>
)}
</div>
<div className='input__toolbar'>
<button
className='link-button'
onClick={handleDetectClick}
disabled={type !== 'image' || isDetecting}
>
<FormattedMessage id='' defaultMessage='Add text from image' />
</button>
<CharacterCounter
max={1500}
text={isDetecting ? '' : description}
/>
</div>
</div>
</form>
</div>
</div>
);
};

View File

@ -581,10 +581,14 @@ class Audio extends PureComponent {
</div>
<div className='video-player__buttons right'>
{!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>}
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id={'download'} icon={DownloadIcon} />
</a>
{!editable && (
<>
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id='download' icon={DownloadIcon} />
</a>
</>
)}
</div>
</div>
</div>

View File

@ -1,18 +0,0 @@
import PropTypes from 'prop-types';
import { length } from 'stringz';
export const CharacterCounter = ({ text, max }) => {
const diff = max - length(text);
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
};
CharacterCounter.propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};

View File

@ -0,0 +1,16 @@
import { length } from 'stringz';
export const CharacterCounter: React.FC<{
text: string;
max: number;
}> = ({ text, max }) => {
const diff = max - length(text);
if (diff < 0) {
return (
<span className='character-counter character-counter--over'>{diff}</span>
);
}
return <span className='character-counter'>{diff}</span>;
};

View File

@ -301,6 +301,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__submit'>
<Button
type='submit'
compact
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()}
/>

View File

@ -65,7 +65,9 @@ export const NotificationFollow: React.FC<{
const account = notification.sampleAccountIds[0];
if (account) {
actions = <FollowButton accountId={notification.sampleAccountIds[0]} />;
actions = (
<FollowButton compact accountId={notification.sampleAccountIds[0]} />
);
additionalContent = <FollowerCount accountId={account} />;
}
}

View File

@ -1,438 +0,0 @@
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';
// 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';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
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 } 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.record.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' iconComponent={CloseIcon} 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={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')} 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 }));

View File

@ -37,7 +37,7 @@ import {
ConfirmLogOutModal,
ConfirmFollowToListModal,
} from './confirmation_modals';
import FocalPointModal from './focal_point_modal';
import { AltTextModal } from 'mastodon/features/alt_text_modal';
import ImageModal from './image_modal';
import MediaModal from './media_modal';
import { ModalPlaceholder } from './modal_placeholder';
@ -64,7 +64,7 @@ export const MODAL_COMPONENTS = {
'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,

View File

@ -85,6 +85,14 @@
outline: $ui-button-icon-focus-outline;
}
&--compact {
font-size: 14px;
line-height: normal;
font-weight: 700;
padding: 5px 12px;
border-radius: 4px;
}
&--dangerous {
background-color: var(--error-background-color);
color: var(--on-error-color);
@ -3724,58 +3732,6 @@ $ui-header-logo-wordmark-width: 99px;
}
}
.setting-text {
display: block;
box-sizing: border-box;
margin: 0;
color: $primary-text-color;
background: $ui-base-color;
padding: 7px 10px;
font-family: inherit;
font-size: 14px;
line-height: 22px;
border-radius: 4px;
border: 1px solid var(--background-border-color);
&:focus {
outline: 0;
}
&__wrapper {
background: $ui-base-color;
border: 1px solid var(--background-border-color);
margin-bottom: 10px;
border-radius: 4px;
.setting-text {
border: 0;
margin-bottom: 0;
border-radius: 0;
&:focus {
border: 0;
}
}
&__modifiers {
color: $inverted-text-color;
font-family: inherit;
font-size: 14px;
background: $white;
}
}
&__toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
@media screen and (width <= 600px) {
font-size: 16px;
}
}
.status-card {
display: flex;
align-items: center;
@ -6094,6 +6050,28 @@ a.status-card {
gap: 16px;
padding: 24px;
}
&__preview {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: center;
padding: 24px;
background: #000;
img {
display: block;
}
img,
.gifv video,
.video-player,
.audio-player {
max-width: 360px;
max-height: 90vh;
}
}
}
.copy-paste-text {
@ -6440,62 +6418,6 @@ a.status-card {
margin-bottom: 29px;
}
.report-modal__comment {
padding: 20px;
border-inline-end: 1px solid var(--background-border-color);
max-width: 320px;
p {
font-size: 14px;
line-height: 20px;
margin-bottom: 20px;
}
.setting-text-label {
display: block;
color: $secondary-text-color;
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
}
.setting-text {
width: 100%;
resize: none;
min-height: 100px;
max-height: 50vh;
border: 0;
@media screen and (height <= 600px) {
max-height: 20vh;
}
@media screen and (max-width: $no-columns-breakpoint) {
max-height: 20vh;
}
}
.setting-toggle {
margin-top: 20px;
margin-bottom: 24px;
&__label {
color: $inverted-text-color;
font-size: 14px;
}
}
@media screen and (width <= 480px) {
padding: 10px;
max-width: 100%;
order: 2;
.setting-toggle {
margin-bottom: 4px;
}
}
}
.actions-modal {
max-height: 80vh;
max-width: 80vw;
@ -7367,6 +7289,14 @@ a.status-card {
}
.gifv {
position: relative;
canvas {
position: absolute;
width: 100%;
height: 100%;
}
video {
max-width: 100vw;
max-height: 80vh;
@ -10454,12 +10384,7 @@ noscript {
.compose-form__actions {
.button {
display: block; // Otherwise text-ellipsis doesn't work
font-size: 14px;
line-height: normal;
font-weight: 700;
flex: 1 1 auto;
padding: 5px 12px;
border-radius: 4px;
}
}

View File

@ -76,6 +76,18 @@ code {
margin-bottom: 16px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
&__toolbar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
&.hidden {
margin: 0;
}
@ -552,9 +564,7 @@ code {
margin-bottom: 15px;
}
button,
.button,
.block-button {
button:not(.button):not(.link-button) {
display: block;
width: 100%;
border: 0;
@ -629,6 +639,18 @@ code {
}
.label_input {
position: relative;
&__loading-indicator {
box-sizing: border-box;
position: absolute;
top: 0;
inset-inline-start: 0;
border: 1px solid transparent;
padding: 10px 16px;
width: 100%;
}
&__wrapper {
position: relative;
}

View File

@ -3,7 +3,7 @@
.simple_form
%p.hint= t('admin.relays.description_html')
= link_to @relays.empty? ? t('admin.relays.setup') : t('admin.relays.add_new'), new_admin_relay_path, class: 'block-button'
= link_to @relays.empty? ? t('admin.relays.setup') : t('admin.relays.add_new'), new_admin_relay_path, class: 'button button--block'
- unless @relays.empty?
%hr.spacer

View File

@ -6,4 +6,4 @@
%hr.spacer/
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'button button--block'

View File

@ -14,4 +14,4 @@
%hr.spacer/
.simple_form
= link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'
= link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'button button--block'

View File

@ -38,4 +38,4 @@
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'button button--block'

View File

@ -122,7 +122,7 @@
"stringz": "^2.1.0",
"substring-trie": "^1.0.2",
"terser-webpack-plugin": "^4.2.3",
"tesseract.js": "^2.1.5",
"tesseract.js": "^5.1.1",
"tiny-queue": "^0.2.1",
"twitter-text": "3.1.0",
"use-debounce": "^10.0.0",

117
yarn.lock
View File

@ -3018,7 +3018,7 @@ __metadata:
stylelint-config-standard-scss: "npm:^14.0.0"
substring-trie: "npm:^1.0.2"
terser-webpack-plugin: "npm:^4.2.3"
tesseract.js: "npm:^2.1.5"
tesseract.js: "npm:^5.1.1"
tiny-queue: "npm:^0.2.1"
twitter-text: "npm:3.1.0"
typescript: "npm:^5.0.4"
@ -5642,13 +5642,6 @@ __metadata:
languageName: node
linkType: hard
"blueimp-load-image@npm:^3.0.0":
version: 3.0.0
resolution: "blueimp-load-image@npm:3.0.0"
checksum: 10c0/e860da4113afd8e58bc026fb17240007e15dc155287a70fb57b3048fc8f0aa5f7dbd052efed8bff19d1208eeab4d058dc6788684a721c50ccd08b68d836a8d18
languageName: node
linkType: hard
"blurhash@npm:^2.0.5":
version: 2.0.5
resolution: "blurhash@npm:2.0.5"
@ -6386,13 +6379,6 @@ __metadata:
languageName: node
linkType: hard
"colors@npm:^1.4.0":
version: 1.4.0
resolution: "colors@npm:1.4.0"
checksum: 10c0/9af357c019da3c5a098a301cf64e3799d27549d8f185d86f79af23069e4f4303110d115da98483519331f6fb71c8568d5688fa1c6523600044fd4a54e97c4efb
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
@ -8727,13 +8713,6 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^12.4.1":
version: 12.4.2
resolution: "file-type@npm:12.4.2"
checksum: 10c0/26a307262a2a0b41ea83136550fbe83d8b502d080778b6577e0336fbfe9e919e1f871a286a6eb59f668425f60ebb19402fcb6c0443af58446d33c63362554e1d
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
@ -9765,10 +9744,10 @@ __metadata:
languageName: node
linkType: hard
"idb-keyval@npm:^3.2.0":
version: 3.2.0
resolution: "idb-keyval@npm:3.2.0"
checksum: 10c0/9b1f65d5f08630ef444a89334370c394175b1543f157621b36a3bc5e5208946f3f0ab5d5e24c74e81f2ef54b55b742b4e5b439c561f62695ffb69a06b0bce8e1
"idb-keyval@npm:^6.2.0":
version: 6.2.1
resolution: "idb-keyval@npm:6.2.1"
checksum: 10c0/9f0c83703a365e00bd0b4ed6380ce509a06dedfc6ec39b2ba5740085069fd2f2ff5c14ba19356488e3612a2f9c49985971982d836460a982a5d0b4019eeba48a
languageName: node
linkType: hard
@ -10238,7 +10217,7 @@ __metadata:
languageName: node
linkType: hard
"is-electron@npm:^2.2.0":
"is-electron@npm:^2.2.2":
version: 2.2.2
resolution: "is-electron@npm:2.2.2"
checksum: 10c0/327bb373f7be01b16cdff3998b5ddaa87d28f576092affaa7fe0659571b3306fdd458afbf0683a66841e7999af13f46ad0e1b51647b469526cd05a4dd736438a
@ -11218,28 +11197,6 @@ __metadata:
languageName: node
linkType: hard
"jpeg-autorotate@npm:^7.1.1":
version: 7.1.1
resolution: "jpeg-autorotate@npm:7.1.1"
dependencies:
colors: "npm:^1.4.0"
glob: "npm:^7.1.6"
jpeg-js: "npm:^0.4.2"
piexifjs: "npm:^1.0.6"
yargs-parser: "npm:^20.2.1"
bin:
jpeg-autorotate: src/cli.js
checksum: 10c0/75328e15b7abcaf8b36c980495cb0b37ffabeb8921e8576312deac8139a9e8a66f85d9196f314120e4633c76623d1b595e65ca7a87b679511ffb804f880a1644
languageName: node
linkType: hard
"jpeg-js@npm:^0.4.2":
version: 0.4.4
resolution: "jpeg-js@npm:0.4.4"
checksum: 10c0/4d0d5097f8e55d8bbce6f1dc32ffaf3f43f321f6222e4e6490734fdc6d005322e3bd6fb992c2df7f5b587343b1441a1c333281dc3285bc9116e369fd2a2b43a7
languageName: node
linkType: hard
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@ -12464,9 +12421,9 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^2.6.0":
version: 2.6.11
resolution: "node-fetch@npm:2.6.11"
"node-fetch@npm:^2.6.9":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
whatwg-url: "npm:^5.0.0"
peerDependencies:
@ -12474,7 +12431,7 @@ __metadata:
peerDependenciesMeta:
encoding:
optional: true
checksum: 10c0/3ec847ca43f678d07b80abfd85bdf06523c2554ee9a494c992c5fc61f5d9cde9f9f16aa33ff09a62f19eee9d54813b8850d7f054cdfee8b2daf789c57f8eeaea
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
languageName: node
linkType: hard
@ -12843,7 +12800,7 @@ __metadata:
languageName: node
linkType: hard
"opencollective-postinstall@npm:^2.0.2":
"opencollective-postinstall@npm:^2.0.3":
version: 2.0.3
resolution: "opencollective-postinstall@npm:2.0.3"
bin:
@ -13326,13 +13283,6 @@ __metadata:
languageName: node
linkType: hard
"piexifjs@npm:^1.0.6":
version: 1.0.6
resolution: "piexifjs@npm:1.0.6"
checksum: 10c0/69a10fe09c08f1e67e653844ac79e720324a7fa34689b020359d60d98b3a601c070e1759df8f2d97d022298bd2f5b79eed4c92de86c5f215300c8a63adf947b1
languageName: node
linkType: hard
"pify@npm:^2.0.0":
version: 2.3.0
resolution: "pify@npm:2.3.0"
@ -17159,31 +17109,28 @@ __metadata:
languageName: node
linkType: hard
"tesseract.js-core@npm:^2.2.0":
version: 2.2.0
resolution: "tesseract.js-core@npm:2.2.0"
checksum: 10c0/9ef569529f1ee96f8bf18388ef086d4940d3f02d28b4252df133a9bd36f6a9d140085e77a12ff1963cf9b4cd85bd1c644b61eca266ecfc51bb83adb30a1f11e3
"tesseract.js-core@npm:^5.1.1":
version: 5.1.1
resolution: "tesseract.js-core@npm:5.1.1"
checksum: 10c0/9324277aa1598e0707ddbc175d4312fb7b4569e3fc42adbb42a5a3a747b5711a83c810c597b375585ec3261df31270730a146ab61c63fd16472a21867472eb31
languageName: node
linkType: hard
"tesseract.js@npm:^2.1.5":
version: 2.1.5
resolution: "tesseract.js@npm:2.1.5"
"tesseract.js@npm:^5.1.1":
version: 5.1.1
resolution: "tesseract.js@npm:5.1.1"
dependencies:
blueimp-load-image: "npm:^3.0.0"
bmp-js: "npm:^0.1.0"
file-type: "npm:^12.4.1"
idb-keyval: "npm:^3.2.0"
is-electron: "npm:^2.2.0"
idb-keyval: "npm:^6.2.0"
is-electron: "npm:^2.2.2"
is-url: "npm:^1.2.4"
jpeg-autorotate: "npm:^7.1.1"
node-fetch: "npm:^2.6.0"
opencollective-postinstall: "npm:^2.0.2"
node-fetch: "npm:^2.6.9"
opencollective-postinstall: "npm:^2.0.3"
regenerator-runtime: "npm:^0.13.3"
resolve-url: "npm:^0.2.1"
tesseract.js-core: "npm:^2.2.0"
tesseract.js-core: "npm:^5.1.1"
wasm-feature-detect: "npm:^1.2.11"
zlibjs: "npm:^0.3.1"
checksum: 10c0/b3aaee9189f3bc7f4217b83e110d0dd4d9afcafc3045b842f72b7ca9beb00bec732bc6b4b00eca14167c16b014c437fcf83dd272a640c9c8b5e1e9b55ea00ff5
checksum: 10c0/46dd9c22c7c86d065c6fab07cdcc10e455c928e9d218e9e2a475eca4fc865bf60b052b3dc3503996fd195763184fb7deb2793083c84c869a6681e53d0329af36
languageName: node
linkType: hard
@ -18022,6 +17969,13 @@ __metadata:
languageName: node
linkType: hard
"wasm-feature-detect@npm:^1.2.11":
version: 1.8.0
resolution: "wasm-feature-detect@npm:1.8.0"
checksum: 10c0/2cb43e91bbf7aa7c121bc76b3133de3ab6dc4f482acc1d2dc46c528e8adb7a51c72df5c2aacf1d219f113c04efd1706f18274d5790542aa5dd49e0644e3ee665
languageName: node
linkType: hard
"watchpack-chokidar2@npm:^2.0.1":
version: 2.0.1
resolution: "watchpack-chokidar2@npm:2.0.1"
@ -18874,13 +18828,6 @@ __metadata:
languageName: node
linkType: hard
"yargs-parser@npm:^20.2.1":
version: 20.2.9
resolution: "yargs-parser@npm:20.2.9"
checksum: 10c0/0685a8e58bbfb57fab6aefe03c6da904a59769bd803a722bb098bd5b0f29d274a1357762c7258fb487512811b8063fb5d2824a3415a0a4540598335b3b086c72
languageName: node
linkType: hard
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"