feat(composer): a single pasted/dropped image lands in its caption field (#129)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Verified first: after a paste the focus stayed in the composer and the caption needed a click. Now, when exactly one image is pasted or dropped into an empty composer, its upload card's caption input takes focus; Enter there sends the board (with any composer text) and Escape returns focus to the composer. Multi-file drops and non-empty composers are unchanged. The target card is matched by file name + mtime because the metadata strip re-wraps the File. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -283,6 +283,12 @@ type UploadCardRendererProps = {
|
|||||||
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||||
onRemove: (file: TUploadContent) => void;
|
onRemove: (file: TUploadContent) => void;
|
||||||
onComplete?: (upload: UploadSuccess) => 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({
|
export function UploadCardRenderer({
|
||||||
isEncrypted,
|
isEncrypted,
|
||||||
@@ -290,6 +296,9 @@ export function UploadCardRenderer({
|
|||||||
setMetadata,
|
setMetadata,
|
||||||
onRemove,
|
onRemove,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
autoFocusCaption,
|
||||||
|
onCaptionSubmit,
|
||||||
|
onCaptionEscape,
|
||||||
}: UploadCardRendererProps) {
|
}: UploadCardRendererProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mediaConfig = useMediaConfig();
|
const mediaConfig = useMediaConfig();
|
||||||
@@ -379,6 +388,16 @@ export function UploadCardRenderer({
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
style={{ marginTop: config.space.S200, width: '100%' }}
|
style={{ marginTop: config.space.S200, width: '100%' }}
|
||||||
|
autoFocus={autoFocusCaption}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCaptionSubmit?.();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCaptionEscape?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
|
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ const EmojiBoard = React.lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
|
/** [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;
|
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
@@ -253,6 +260,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
selectedFiles.map((f) => f.file),
|
selectedFiles.map((f) => f.file),
|
||||||
);
|
);
|
||||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
|
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(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<File | undefined>(undefined);
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||||
|
|
||||||
@@ -379,12 +389,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
setUploadBoard(true);
|
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({
|
setSelectedFiles({
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
item: await filesToUploadItems(room, files, stripImageMetadata),
|
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 pickFile = useFilePicker(handleFiles, true);
|
||||||
const handleFilePaste = useFilePasteHandler(handleFiles);
|
const handleFilePaste = useFilePasteHandler(handleFiles);
|
||||||
@@ -1037,6 +1052,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
fileItem={fileItem}
|
fileItem={fileItem}
|
||||||
setMetadata={handleFileMetadata}
|
setMetadata={handleFileMetadata}
|
||||||
onRemove={handleRemoveUpload}
|
onRemove={handleRemoveUpload}
|
||||||
|
autoFocusCaption={isAutoFocusTarget(
|
||||||
|
fileItem.originalFile,
|
||||||
|
autoFocusCaptionRef.current,
|
||||||
|
)}
|
||||||
|
onCaptionSubmit={submit}
|
||||||
|
onCaptionEscape={() => ReactEditor.focus(editor)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</UploadBoardContent>
|
</UploadBoardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user