mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-02-19 00:53:51 -05:00
538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
import {
|
|
useState,
|
|
useCallback,
|
|
useRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
forwardRef,
|
|
} from 'react';
|
|
|
|
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
|
|
|
import classNames from 'classnames';
|
|
|
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
|
|
|
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 { showAlertForError } from 'mastodon/actions/alerts';
|
|
import { uploadThumbnail } from 'mastodon/actions/compose';
|
|
import { changeUploadCompose } from 'mastodon/actions/compose_typed';
|
|
import { Button } from 'mastodon/components/button';
|
|
import { GIFV } from 'mastodon/components/gifv';
|
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|
import { Skeleton } from 'mastodon/components/skeleton';
|
|
import Audio from 'mastodon/features/audio';
|
|
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
|
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
|
import Video, { getPointerPosition } from 'mastodon/features/video';
|
|
import { me } from 'mastodon/initial_state';
|
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
|
import { assetHost } from 'mastodon/utils/config';
|
|
|
|
import { InfoButton } from './components/info_button';
|
|
|
|
const messages = defineMessages({
|
|
placeholderVisual: {
|
|
id: 'alt_text_modal.describe_for_people_with_visual_impairments',
|
|
defaultMessage: 'Describe this for people with visual impairments…',
|
|
},
|
|
placeholderHearing: {
|
|
id: 'alt_text_modal.describe_for_people_with_hearing_impairments',
|
|
defaultMessage: 'Describe this for people with hearing impairments…',
|
|
},
|
|
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 MAX_LENGTH = 1500;
|
|
|
|
type FocalPoint = [number, number];
|
|
|
|
const UploadButton: React.FC<{
|
|
children: React.ReactNode;
|
|
onSelectFile: (arg0: File) => void;
|
|
mimeTypes: string;
|
|
}> = ({ children, onSelectFile, mimeTypes }) => {
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleClick = useCallback(() => {
|
|
fileRef.current?.click();
|
|
}, []);
|
|
|
|
const handleChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
|
|
if (file) {
|
|
onSelectFile(file);
|
|
}
|
|
},
|
|
[onSelectFile],
|
|
);
|
|
|
|
return (
|
|
<label>
|
|
<Button onClick={handleClick}>{children}</Button>
|
|
|
|
<input
|
|
id='upload-modal__thumbnail'
|
|
ref={fileRef}
|
|
type='file'
|
|
accept={mimeTypes}
|
|
onChange={handleChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</label>
|
|
);
|
|
};
|
|
|
|
const Preview: React.FC<{
|
|
mediaId: string;
|
|
position: FocalPoint;
|
|
onPositionChange: (arg0: FocalPoint) => void;
|
|
}> = ({ mediaId, position, onPositionChange }) => {
|
|
const media = useAppSelector((state) =>
|
|
(
|
|
(state.compose as ImmutableMap<string, unknown>).get(
|
|
'media_attachments',
|
|
) as ImmutableList<MediaAttachment>
|
|
).find((x) => x.get('id') === mediaId),
|
|
);
|
|
const account = useAppSelector((state) =>
|
|
me ? state.accounts.get(me) : undefined,
|
|
);
|
|
|
|
const [dragging, setDragging] = useState(false);
|
|
const [x, y] = position;
|
|
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
|
|
const draggingRef = useRef<boolean>(false);
|
|
|
|
const setRef = useCallback(
|
|
(e: HTMLImageElement | HTMLVideoElement | null) => {
|
|
nodeRef.current = e;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
|
setDragging(true);
|
|
draggingRef.current = true;
|
|
onPositionChange([x, y]);
|
|
},
|
|
[setDragging, onPositionChange],
|
|
);
|
|
|
|
const handleTouchStart = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
|
setDragging(true);
|
|
draggingRef.current = true;
|
|
onPositionChange([x, y]);
|
|
},
|
|
[setDragging, onPositionChange],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleMouseUp = () => {
|
|
setDragging(false);
|
|
draggingRef.current = false;
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (draggingRef.current) {
|
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
|
onPositionChange([x, y]);
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
setDragging(false);
|
|
draggingRef.current = false;
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
if (draggingRef.current) {
|
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
|
onPositionChange([x, y]);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('touchend', handleTouchEnd);
|
|
document.addEventListener('touchmove', handleTouchMove);
|
|
|
|
return () => {
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('touchend', handleTouchEnd);
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
};
|
|
}, [setDragging, onPositionChange]);
|
|
|
|
if (!media) {
|
|
return null;
|
|
}
|
|
|
|
if (media.get('type') === 'image') {
|
|
return (
|
|
<div className={classNames('focal-point', { dragging })}>
|
|
<img
|
|
ref={setRef}
|
|
draggable={false}
|
|
src={media.get('url') as string}
|
|
alt=''
|
|
role='presentation'
|
|
onMouseDown={handleMouseDown}
|
|
onTouchStart={handleTouchStart}
|
|
/>
|
|
<div
|
|
className='focal-point__reticle'
|
|
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
} else if (media.get('type') === 'gifv') {
|
|
return (
|
|
<div className={classNames('focal-point', { dragging })}>
|
|
<GIFV
|
|
ref={setRef}
|
|
src={media.get('url') as string}
|
|
alt=''
|
|
onMouseDown={handleMouseDown}
|
|
onTouchStart={handleTouchStart}
|
|
/>
|
|
<div
|
|
className='focal-point__reticle'
|
|
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
} else if (media.get('type') === 'video') {
|
|
return (
|
|
<Video
|
|
preview={media.get('preview_url') as string}
|
|
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
|
|
blurhash={media.get('blurhash') as string}
|
|
src={media.get('url') as string}
|
|
detailed
|
|
inline
|
|
editable
|
|
/>
|
|
);
|
|
} else if (media.get('type') === 'audio') {
|
|
return (
|
|
<Audio
|
|
src={media.get('url') as string}
|
|
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
|
poster={
|
|
(media.get('preview_url') as string | undefined) ??
|
|
account?.avatar_static
|
|
}
|
|
backgroundColor={
|
|
media.getIn(['meta', 'colors', 'background']) as string
|
|
}
|
|
foregroundColor={
|
|
media.getIn(['meta', 'colors', 'foreground']) as string
|
|
}
|
|
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
|
|
editable
|
|
/>
|
|
);
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
interface RestoreProps {
|
|
previousDescription: string;
|
|
previousPosition: FocalPoint;
|
|
}
|
|
|
|
interface Props {
|
|
mediaId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface ConfirmationMessage {
|
|
message: string;
|
|
confirm: string;
|
|
props?: RestoreProps;
|
|
}
|
|
|
|
export interface ModalRef {
|
|
getCloseConfirmationMessage: () => null | ConfirmationMessage;
|
|
}
|
|
|
|
export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
|
({ mediaId, previousDescription, previousPosition, onClose }, ref) => {
|
|
const intl = useIntl();
|
|
const dispatch = useAppDispatch();
|
|
const media = useAppSelector((state) =>
|
|
(
|
|
(state.compose as ImmutableMap<string, unknown>).get(
|
|
'media_attachments',
|
|
) as ImmutableList<MediaAttachment>
|
|
).find((x) => x.get('id') === mediaId),
|
|
);
|
|
const lang = useAppSelector(
|
|
(state) =>
|
|
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
|
);
|
|
const focusX =
|
|
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
|
const focusY =
|
|
(media?.getIn(['meta', 'focus', 'y'], 0) as number | undefined) ?? 0;
|
|
const [description, setDescription] = useState(
|
|
previousDescription ??
|
|
(media?.get('description') as string | undefined) ??
|
|
'',
|
|
);
|
|
const [position, setPosition] = useState<FocalPoint>(
|
|
previousPosition ?? [focusX / 2 + 0.5, focusY / -2 + 0.5],
|
|
);
|
|
const [isDetecting, setIsDetecting] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const dirtyRef = useRef(
|
|
previousDescription || previousPosition ? true : false,
|
|
);
|
|
const type = media?.get('type') as string;
|
|
const valid = length(description) <= MAX_LENGTH;
|
|
|
|
const handleDescriptionChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setDescription(e.target.value);
|
|
dirtyRef.current = true;
|
|
},
|
|
[setDescription],
|
|
);
|
|
|
|
const handleThumbnailChange = useCallback(
|
|
(file: File) => {
|
|
dispatch(uploadThumbnail(mediaId, file));
|
|
},
|
|
[dispatch, mediaId],
|
|
);
|
|
|
|
const handlePositionChange = useCallback(
|
|
(position: FocalPoint) => {
|
|
setPosition(position);
|
|
dirtyRef.current = true;
|
|
},
|
|
[setPosition],
|
|
);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
setIsSaving(true);
|
|
|
|
dispatch(
|
|
changeUploadCompose({
|
|
id: mediaId,
|
|
description,
|
|
focus: `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}`,
|
|
}),
|
|
)
|
|
.then(() => {
|
|
setIsSaving(false);
|
|
dirtyRef.current = false;
|
|
onClose();
|
|
return '';
|
|
})
|
|
.catch((err: unknown) => {
|
|
setIsSaving(false);
|
|
dispatch(showAlertForError(err));
|
|
});
|
|
}, [dispatch, setIsSaving, mediaId, onClose, position, description]);
|
|
|
|
const handleKeyUp = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
|
|
if (valid) {
|
|
handleSubmit();
|
|
}
|
|
}
|
|
},
|
|
[handleSubmit, valid],
|
|
);
|
|
|
|
const handleDetectClick = useCallback(() => {
|
|
setIsDetecting(true);
|
|
|
|
fetchTesseract()
|
|
.then(async ({ createWorker }) => {
|
|
const worker = await createWorker('eng', 1, {
|
|
workerPath: tesseractWorkerPath as string,
|
|
corePath: tesseractCorePath as string,
|
|
langPath: `${assetHost}/ocr/lang-data`,
|
|
cacheMethod: 'write',
|
|
});
|
|
|
|
const image = URL.createObjectURL(media?.get('file') as File);
|
|
const result = await worker.recognize(image);
|
|
|
|
setDescription(result.data.text);
|
|
setIsDetecting(false);
|
|
|
|
await worker.terminate();
|
|
|
|
return '';
|
|
})
|
|
.catch(() => {
|
|
setIsDetecting(false);
|
|
});
|
|
}, [setDescription, setIsDetecting, media]);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
getCloseConfirmationMessage: () => {
|
|
if (dirtyRef.current) {
|
|
return {
|
|
message: intl.formatMessage(messages.discardMessage),
|
|
confirm: intl.formatMessage(messages.discardConfirm),
|
|
props: {
|
|
previousDescription: description,
|
|
previousPosition: position,
|
|
},
|
|
};
|
|
}
|
|
|
|
return null;
|
|
},
|
|
}),
|
|
[intl, description, position],
|
|
);
|
|
|
|
return (
|
|
<div className='modal-root__modal dialog-modal'>
|
|
<div className='dialog-modal__header'>
|
|
<Button onClick={handleSubmit} disabled={!valid}>
|
|
{isSaving ? (
|
|
<LoadingIndicator />
|
|
) : (
|
|
<FormattedMessage
|
|
id='alt_text_modal.done'
|
|
defaultMessage='Done'
|
|
/>
|
|
)}
|
|
</Button>
|
|
|
|
<span className='dialog-modal__header__title'>
|
|
<FormattedMessage
|
|
id='alt_text_modal.add_alt_text'
|
|
defaultMessage='Add alt text'
|
|
/>
|
|
</span>
|
|
|
|
<Button secondary onClick={onClose}>
|
|
<FormattedMessage
|
|
id='alt_text_modal.cancel'
|
|
defaultMessage='Cancel'
|
|
/>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className='dialog-modal__content'>
|
|
<div className='dialog-modal__content__preview'>
|
|
<Preview
|
|
mediaId={mediaId}
|
|
position={position}
|
|
onPositionChange={handlePositionChange}
|
|
/>
|
|
|
|
{(type === 'audio' || type === 'video') && (
|
|
<UploadButton
|
|
onSelectFile={handleThumbnailChange}
|
|
mimeTypes='image/jpeg,image/png,image/gif,image/heic,image/heif,image/webp,image/avif'
|
|
>
|
|
<FormattedMessage
|
|
id='alt_text_modal.change_thumbnail'
|
|
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}
|
|
onKeyUp={handleKeyUp}
|
|
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'>
|
|
<CharacterCounter
|
|
max={MAX_LENGTH}
|
|
text={isDetecting ? '' : description}
|
|
/>
|
|
|
|
<div className='spacer' />
|
|
|
|
<button
|
|
className='link-button'
|
|
onClick={handleDetectClick}
|
|
disabled={type !== 'image' || isDetecting}
|
|
>
|
|
<FormattedMessage
|
|
id='alt_text_modal.add_text_from_image'
|
|
defaultMessage='Add text from image'
|
|
/>
|
|
</button>
|
|
|
|
<InfoButton />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
AltTextModal.displayName = 'AltTextModal';
|