pl-fe: copy mastodon focal points implementation but make it worse
Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
@ -19,7 +19,7 @@ import { saveSettings } from './settings';
|
||||
import { createStatus } from './statuses';
|
||||
|
||||
import type { EditorState } from 'lexical';
|
||||
import type { Account as BaseAccount, CreateStatusParams, CustomEmoji, Group, MediaAttachment, Status as BaseStatus, Tag, Poll, ScheduledStatus, InteractionPolicy } from 'pl-api';
|
||||
import type { Account as BaseAccount, CreateStatusParams, CustomEmoji, Group, MediaAttachment, Status as BaseStatus, Tag, Poll, ScheduledStatus, InteractionPolicy, UpdateMediaParams } from 'pl-api';
|
||||
import type { AutoSuggestion } from 'pl-fe/components/autosuggest-input';
|
||||
import type { Emoji } from 'pl-fe/features/emoji';
|
||||
import type { Policy, Rule, Scope } from 'pl-fe/features/interaction-policies';
|
||||
@ -527,7 +527,7 @@ const uploadComposeFail = (composeId: string, error: unknown) => ({
|
||||
error,
|
||||
});
|
||||
|
||||
const changeUploadCompose = (composeId: string, mediaId: string, params: Record<string, any>) =>
|
||||
const changeUploadCompose = (composeId: string, mediaId: string, params: UpdateMediaParams) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return Promise.resolve();
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import resizeImage from 'pl-fe/utils/resize-image';
|
||||
|
||||
import { getClient } from '../api';
|
||||
|
||||
import type { MediaAttachment, UploadMediaParams } from 'pl-api';
|
||||
import type { MediaAttachment, UpdateMediaParams, UploadMediaParams } from 'pl-api';
|
||||
import type { AppDispatch, RootState } from 'pl-fe/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -18,7 +18,7 @@ const messages = defineMessages({
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
const updateMedia = (mediaId: string, params: Record<string, any>) =>
|
||||
const updateMedia = (mediaId: string, params: UpdateMediaParams) =>
|
||||
(_dispatch: AppDispatch, getState: () => RootState) =>
|
||||
getClient(getState()).media.updateMedia(mediaId, params);
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@ interface IUpload extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onDragStar
|
||||
media: MediaAttachment;
|
||||
onSubmit?(): void;
|
||||
onDelete?(): void;
|
||||
onDescriptionChange?(description: string): Promise<void>;
|
||||
onDescriptionChange?(description: string, position: [number, number]): Promise<void>;
|
||||
descriptionLimit?: number;
|
||||
withPreview?: boolean;
|
||||
}
|
||||
@ -103,11 +103,16 @@ const Upload: React.FC<IUpload> = ({
|
||||
|
||||
if (!onDescriptionChange) return;
|
||||
|
||||
const focusX = (media.type === 'image' || media.type === 'gifv') && media.meta.focus?.x || 0;
|
||||
const focusY = (media.type === 'image' || media.type === 'gifv') && media.meta.focus?.y || 0;
|
||||
|
||||
openModal('ALT_TEXT', {
|
||||
media,
|
||||
withPosition: !!onDragStart,
|
||||
previousDescription: media.description,
|
||||
previousPosition: [focusX / 2 + 0.5, focusY / -2 + 0.5],
|
||||
descriptionLimit: descriptionLimit!,
|
||||
onSubmit: (newDescription: string) => onDescriptionChange(newDescription),
|
||||
onSubmit: (newDescription: string, newPosition: [number, number]) => onDescriptionChange(newDescription, newPosition),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -21,8 +21,11 @@ const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id, onSubmit, onDr
|
||||
|
||||
const media = useCompose(composeId).media_attachments.find(item => item.id === id)!;
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
return dispatch(changeUploadCompose(composeId, media.id, { description }));
|
||||
const handleDescriptionChange = (description: string, position?: [number, number]) => {
|
||||
return dispatch(changeUploadCompose(composeId, media.id, {
|
||||
description,
|
||||
focus: position ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import defaultIcon from '@tabler/icons/outline/paperclip.svg';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import Blurhash from 'pl-fe/components/blurhash';
|
||||
@ -9,13 +10,17 @@ import Modal from 'pl-fe/components/ui/modal';
|
||||
import Stack from 'pl-fe/components/ui/stack';
|
||||
import Textarea from 'pl-fe/components/ui/textarea';
|
||||
import { MIMETYPE_ICONS } from 'pl-fe/components/upload';
|
||||
import { getPointerPosition } from 'pl-fe/features/video';
|
||||
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useCompose } from 'pl-fe/hooks/use-compose';
|
||||
import { useFeatures } from 'pl-fe/hooks/use-features';
|
||||
import toast from 'pl-fe/toast';
|
||||
|
||||
import type { BaseModalProps } from '../modal-root';
|
||||
import type { MediaAttachment } from 'pl-api';
|
||||
|
||||
type FocalPoint = [number, number];
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholderVisual: {
|
||||
id: 'alt_text_modal.describe_for_people_with_visual_impairments',
|
||||
@ -31,23 +36,91 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
interface AltTextModalProps {
|
||||
composeId?: string;
|
||||
previousDescription: string;
|
||||
descriptionLimit: number;
|
||||
interface PreviewProps {
|
||||
media: MediaAttachment;
|
||||
onSubmit: (description: string) => Promise<void>;
|
||||
position: FocalPoint;
|
||||
onPositionChange: (position: FocalPoint) => void;
|
||||
withPosition: boolean;
|
||||
}
|
||||
|
||||
const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId, media, onClose, onSubmit, previousDescription, descriptionLimit }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const Preview: React.FC<PreviewProps> = ({ media, position: [x, y], onPositionChange, withPosition }) => {
|
||||
const { focalPoint } = useFeatures();
|
||||
const withFocalPoint = withPosition && focalPoint && (media.type === 'image' || media.type === 'gifv');
|
||||
|
||||
const { language } = useCompose(composeId || 'default');
|
||||
// const [dragging, setDragging] = useState(false);
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef<boolean>(false);
|
||||
|
||||
const [description, setDescription] = useState(media.description || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const dirtyRef = useRef(previousDescription ? true : false);
|
||||
const setRef = useCallback(
|
||||
(e: HTMLDivElement | null) => {
|
||||
nodeRef.current = e;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || !nodeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
// setDragging(true);
|
||||
draggingRef.current = true;
|
||||
onPositionChange([x, y]);
|
||||
},
|
||||
[onPositionChange],
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
// setDragging(true);
|
||||
draggingRef.current = true;
|
||||
onPositionChange([x, y]);
|
||||
},
|
||||
[onPositionChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => {
|
||||
// setDragging(false);
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (draggingRef.current && nodeRef.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 && nodeRef.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);
|
||||
};
|
||||
}, [onPositionChange]);
|
||||
|
||||
const uploadIcon = media.type === 'unknown' && (
|
||||
<Icon
|
||||
@ -56,6 +129,80 @@ const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId,
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden rounded-md'>
|
||||
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
|
||||
<div
|
||||
className={clsx(
|
||||
'relative h-64 max-h-96 w-full overflow-hidden bg-contain bg-center bg-no-repeat',
|
||||
{ 'cursor-grab': withFocalPoint },
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: media.type === 'image' || media.type === 'gifv' ? `url(${media.preview_url})` : undefined,
|
||||
height: media.type === 'image' || media.type === 'video' ? media.meta.original?.height : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setRef}
|
||||
className='absolute inset-0 size-full'
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{media.type === 'video' && (
|
||||
<video className='size-full object-cover' autoPlay playsInline muted loop>
|
||||
<source src={media.preview_url} />
|
||||
</video>
|
||||
)}
|
||||
{uploadIcon}
|
||||
</div>
|
||||
</div>
|
||||
{withFocalPoint && (
|
||||
<div
|
||||
className='pointer-events-none absolute h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white'
|
||||
style={{
|
||||
top: `${y * 100}%`,
|
||||
left: `${x * 100}%`,
|
||||
boxShadow: '0 0 0 9999em rgba(0,0,0,.35)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className='absolute inset-x-2 bottom-2 w-fit overflow-hidden text-ellipsis rounded bg-gray-900 px-2 py-1 text-xs font-medium text-white'>
|
||||
{media.url.split('/').at(-1)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AltTextModalProps {
|
||||
composeId?: string;
|
||||
descriptionLimit: number;
|
||||
media: MediaAttachment;
|
||||
onSubmit: (description: string, position: FocalPoint) => Promise<void>;
|
||||
previousDescription: string;
|
||||
previousPosition: FocalPoint;
|
||||
withPosition: boolean;
|
||||
}
|
||||
|
||||
const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({
|
||||
composeId,
|
||||
descriptionLimit,
|
||||
media,
|
||||
onClose,
|
||||
onSubmit,
|
||||
previousDescription,
|
||||
previousPosition,
|
||||
withPosition,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const { language } = useCompose(composeId || 'default');
|
||||
|
||||
const [description, setDescription] = useState(previousDescription || '');
|
||||
const [position, setPosition] = useState(previousPosition || [0, 0]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const dirtyRef = useRef(previousDescription ? true : false);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDescription(e.target.value);
|
||||
@ -64,10 +211,18 @@ const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId,
|
||||
[setDescription],
|
||||
);
|
||||
|
||||
const handlePositionChange = useCallback(
|
||||
(position: FocalPoint) => {
|
||||
setPosition(position);
|
||||
dirtyRef.current = true;
|
||||
},
|
||||
[setPosition],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
|
||||
onSubmit(description).then(() => {
|
||||
onSubmit(description, position).then(() => {
|
||||
setIsSaving(false);
|
||||
dirtyRef.current = false;
|
||||
onClose();
|
||||
@ -76,7 +231,7 @@ const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId,
|
||||
setIsSaving(false);
|
||||
toast.error(messages.savingFailed);
|
||||
});
|
||||
}, [dispatch, setIsSaving, media.id, onClose, description]);
|
||||
}, [dispatch, setIsSaving, media.id, onClose, description, position]);
|
||||
|
||||
const handleKeyUp = useCallback((e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
@ -98,28 +253,7 @@ const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId,
|
||||
secondaryText={<FormattedMessage id='alt_text_modal.cancel' defaultMessage='Cancel' />}
|
||||
>
|
||||
<Stack space={2}>
|
||||
<div className='relative overflow-hidden rounded-md'>
|
||||
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
|
||||
<div
|
||||
className='relative h-64 max-h-96 w-full overflow-hidden bg-contain bg-center bg-no-repeat'
|
||||
style={{
|
||||
backgroundImage: media.type === 'image' ? `url(${media.preview_url})` : undefined,
|
||||
height: media.type === 'image' || media.type === 'video' ? media.meta.original?.height : undefined,
|
||||
}}
|
||||
>
|
||||
<div className='absolute inset-0 size-full'>
|
||||
{media.type === 'video' && (
|
||||
<video className='size-full object-cover' autoPlay playsInline muted loop>
|
||||
<source src={media.preview_url} />
|
||||
</video>
|
||||
)}
|
||||
{uploadIcon}
|
||||
</div>
|
||||
<span className='absolute inset-x-2 bottom-2 w-fit overflow-hidden text-ellipsis rounded bg-gray-900 px-2 py-1 text-xs font-medium text-white'>
|
||||
{media.url.split('/').at(-1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Preview media={media} withPosition={withPosition} position={position} onPositionChange={handlePositionChange} />
|
||||
<form>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(
|
||||
@ -129,7 +263,6 @@ const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId,
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className=''
|
||||
value={description}
|
||||
maxLength={descriptionLimit}
|
||||
onChange={handleDescriptionChange}
|
||||
|
||||
@ -62,19 +62,24 @@ const findElementPosition = (el: HTMLElement) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Position => {
|
||||
const getPointerPosition = (
|
||||
el: HTMLElement,
|
||||
event: Pick<MouseEvent, 'pageX' | 'pageY'> | Pick<TouchEvent, 'changedTouches'> | Pick<React.TouchEvent, 'changedTouches'>,
|
||||
): Position => {
|
||||
const box = findElementPosition(el);
|
||||
const boxW = el.offsetWidth;
|
||||
const boxH = el.offsetHeight;
|
||||
const boxY = box.top;
|
||||
const boxX = box.left;
|
||||
|
||||
let pageY = event.pageY;
|
||||
let pageX = event.pageX;
|
||||
let pageX, pageY;
|
||||
|
||||
if (event.changedTouches) {
|
||||
if ('changedTouches' in event) {
|
||||
pageX = event.changedTouches[0].pageX;
|
||||
pageY = event.changedTouches[0].pageY;
|
||||
} else {
|
||||
pageX = event.pageX;
|
||||
pageY = event.pageY;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -211,6 +211,7 @@
|
||||
"aliases.search": "Search your old account",
|
||||
"aliases.success.add": "Account alias created successfully",
|
||||
"aliases.success.remove": "Account alias removed successfully",
|
||||
"alt_text_modal.cancel": "Cancel",
|
||||
"alt_text_modal.confirmation": "Save",
|
||||
"alt_text_modal.describe_for_people_with_hearing_impairments": "Describe this for people with hearing impairments…",
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "Describe this for people with visual impairments…",
|
||||
|
||||
Reference in New Issue
Block a user