diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 281188d6f..c399d08c3 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -283,6 +283,12 @@ type UploadCardRendererProps = { setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void; onRemove: (file: TUploadContent) => void; onComplete?: (upload: UploadSuccess) => void; + /** [Gitea #129] Focus the caption field on mount (single image into an empty composer). */ + autoFocusCaption?: boolean; + /** Enter in the caption field: send the board (and any composer text). */ + onCaptionSubmit?: () => void; + /** Escape in the caption field: hand focus back to the composer. */ + onCaptionEscape?: () => void; }; export function UploadCardRenderer({ isEncrypted, @@ -290,6 +296,9 @@ export function UploadCardRenderer({ setMetadata, onRemove, onComplete, + autoFocusCaption, + onCaptionSubmit, + onCaptionEscape, }: UploadCardRendererProps) { const mx = useMatrixClient(); const mediaConfig = useMediaConfig(); @@ -379,6 +388,16 @@ export function UploadCardRenderer({ size="300" radii="300" style={{ marginTop: config.space.S200, width: '100%' }} + autoFocus={autoFocusCaption} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + onCaptionSubmit?.(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCaptionEscape?.(); + } + }} /> )} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 5e2bbd140..62a424115 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -158,6 +158,13 @@ const EmojiBoard = React.lazy(() => ); /** [Gitea #37] Debounce for persisting the composer draft while typing. */ +/** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */ +const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean => + !!target && + file instanceof File && + file.name === target.name && + file.lastModified === target.lastModified; + const DRAFT_PERSIST_DEBOUNCE_MS = 500; interface RoomInputProps { @@ -253,6 +260,9 @@ export const RoomInput = forwardRef( selectedFiles.map((f) => f.file), ); const uploadBoardHandlers = useRef(undefined); + // [Gitea #129] The file whose caption input should take focus once its card mounts. + // (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.) + const autoFocusCaptionRef = useRef(undefined); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); @@ -379,12 +389,17 @@ export const RoomInput = forwardRef( const handleFiles = useCallback( async (files: File[]) => { setUploadBoard(true); + // [Gitea #129] One image into an empty composer: land in its caption + // field so "here's the bug" is one motion (Enter sends, Esc returns). + const focusCaption = + files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor); setSelectedFiles({ type: 'PUT', item: await filesToUploadItems(room, files, stripImageMetadata), }); + if (focusCaption) autoFocusCaptionRef.current = files[0]; }, - [setSelectedFiles, room, stripImageMetadata], + [setSelectedFiles, room, stripImageMetadata, editor], ); const pickFile = useFilePicker(handleFiles, true); const handleFilePaste = useFilePasteHandler(handleFiles); @@ -1037,6 +1052,12 @@ export const RoomInput = forwardRef( fileItem={fileItem} setMetadata={handleFileMetadata} onRemove={handleRemoveUpload} + autoFocusCaption={isAutoFocusTarget( + fileItem.originalFile, + autoFocusCaptionRef.current, + )} + onCaptionSubmit={submit} + onCaptionEscape={() => ReactEditor.focus(editor)} /> ))}