diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index f34561cf8..b6cb9184c 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -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) => +const changeUploadCompose = (composeId: string, mediaId: string, params: UpdateMediaParams) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return Promise.resolve(); diff --git a/packages/pl-fe/src/actions/media.ts b/packages/pl-fe/src/actions/media.ts index 0879dfe6f..d5d9d4f23 100644 --- a/packages/pl-fe/src/actions/media.ts +++ b/packages/pl-fe/src/actions/media.ts @@ -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) => +const updateMedia = (mediaId: string, params: UpdateMediaParams) => (_dispatch: AppDispatch, getState: () => RootState) => getClient(getState()).media.updateMedia(mediaId, params); diff --git a/packages/pl-fe/src/components/upload.tsx b/packages/pl-fe/src/components/upload.tsx index 0e0c88113..dcf751360 100644 --- a/packages/pl-fe/src/components/upload.tsx +++ b/packages/pl-fe/src/components/upload.tsx @@ -68,7 +68,7 @@ interface IUpload extends Pick, 'onDragStar media: MediaAttachment; onSubmit?(): void; onDelete?(): void; - onDescriptionChange?(description: string): Promise; + onDescriptionChange?(description: string, position: [number, number]): Promise; descriptionLimit?: number; withPreview?: boolean; } @@ -103,11 +103,16 @@ const Upload: React.FC = ({ 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), }); }; diff --git a/packages/pl-fe/src/features/compose/components/upload.tsx b/packages/pl-fe/src/features/compose/components/upload.tsx index e4a470335..001ff9357 100644 --- a/packages/pl-fe/src/features/compose/components/upload.tsx +++ b/packages/pl-fe/src/features/compose/components/upload.tsx @@ -21,8 +21,11 @@ const UploadCompose: React.FC = ({ 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 = () => { diff --git a/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx b/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx index 27ed22106..c9fe56b66 100644 --- a/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx +++ b/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx @@ -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; + position: FocalPoint; + onPositionChange: (position: FocalPoint) => void; + withPosition: boolean; } -const AltTextModal: React.FC = ({ composeId, media, onClose, onSubmit, previousDescription, descriptionLimit }) => { - const dispatch = useAppDispatch(); - const intl = useIntl(); +const Preview: React.FC = ({ 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(null); + const draggingRef = useRef(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' && ( = ({ composeId, /> ); + return ( +
+ +
+
+ {media.type === 'video' && ( + + )} + {uploadIcon} +
+
+ {withFocalPoint && ( +
+ )} + + {media.url.split('/').at(-1)} + +
+ ); +}; + +interface AltTextModalProps { + composeId?: string; + descriptionLimit: number; + media: MediaAttachment; + onSubmit: (description: string, position: FocalPoint) => Promise; + previousDescription: string; + previousPosition: FocalPoint; + withPosition: boolean; +} + +const AltTextModal: React.FC = ({ + 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) => { setDescription(e.target.value); @@ -64,10 +211,18 @@ const AltTextModal: React.FC = ({ 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 = ({ 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 = ({ composeId, secondaryText={} > -
- -
-
- {media.type === 'video' && ( - - )} - {uploadIcon} -
- - {media.url.split('/').at(-1)} - -
-
+
= ({ composeId, )} >