pl-fe: copy mastodon focal points implementation but make it worse

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk
2025-03-25 11:42:04 +01:00
parent 5e74517f3f
commit 602c9604ef
7 changed files with 197 additions and 50 deletions

View File

@ -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();

View File

@ -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);

View File

@ -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),
});
};

View File

@ -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 = () => {

View File

@ -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}

View File

@ -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 {

View File

@ -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…",