diff --git a/src/App.tsx b/src/App.tsx index 77ba340..c532744 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { FormEvent, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, ArrowLeft, @@ -34,6 +34,7 @@ import helpIconUrl from './assets/qortium-help-protoicon-black-transparent.png'; import { AttachmentList } from './attachments'; import { formatAttachmentSize, + getTransferFiles, MAX_ATTACHMENT_COUNT, prepareFeedbackAttachment, prepareFeedbackBundle, @@ -368,22 +369,24 @@ function AttachmentPicker({ disabled, files, onChange, + pasteTargetRef, }: { disabled: boolean; files: PreparedFeedbackAttachment[]; onChange: (files: PreparedFeedbackAttachment[]) => void; + pasteTargetRef?: RefObject; }) { const inputRef = useRef(null); const [processing, setProcessing] = useState(false); const [error, setError] = useState(''); - async function addFiles(fileList: FileList | null) { - if (!fileList || fileList.length === 0 || disabled || processing) { + async function addFiles(input: readonly File[]) { + if (input.length === 0 || disabled || processing) { return; } const available = Math.max(0, MAX_ATTACHMENT_COUNT - files.length); - const selected = Array.from(fileList).slice(0, available); + const selected = input.slice(0, available); if (selected.length === 0) { setError(`A maximum of ${MAX_ATTACHMENT_COUNT} attachments is supported.`); @@ -408,6 +411,29 @@ function AttachmentPicker({ } } + useEffect(() => { + const target = pasteTargetRef?.current; + + if (!target) { + return; + } + + function handlePaste(event: ClipboardEvent) { + const pastedFiles = getTransferFiles(event.clipboardData); + + if (pastedFiles.length === 0 || disabled || processing || files.length >= MAX_ATTACHMENT_COUNT) { + return; + } + + event.preventDefault(); + void addFiles(pastedFiles); + } + + target.addEventListener('paste', handlePaste); + + return () => target.removeEventListener('paste', handlePaste); + }, [disabled, files.length, pasteTargetRef, processing]); + return (
= MAX_ATTACHMENT_COUNT} multiple onChange={(event) => { - void addFiles(event.target.files); + void addFiles(Array.from(event.target.files ?? [])); }} ref={inputRef} type="file" @@ -504,6 +530,7 @@ function PostComposer({ const [body, setBody] = useState(''); const [attachments, setAttachments] = useState([]); const [draftIdentity, setDraftIdentity] = useState(null); + const bodyInputRef = useRef(null); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -613,6 +640,7 @@ function PostComposer({ disabled={!canPublish || publishing} maxLength={12000} onChange={(event) => setBody(event.target.value)} + ref={bodyInputRef} rows={8} value={body} /> @@ -621,6 +649,7 @@ function PostComposer({ disabled={!canAttach || publishing} files={attachments} onChange={setAttachments} + pasteTargetRef={bodyInputRef} />
{publishName || t('status.noName')} @@ -781,6 +810,7 @@ function ReplyComposer({ const [body, setBody] = useState(''); const [attachments, setAttachments] = useState([]); const [draftIdentity, setDraftIdentity] = useState(null); + const bodyInputRef = useRef(null); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -812,6 +842,7 @@ function ReplyComposer({ disabled={!canPublish || publishing} maxLength={12000} onChange={(event) => setBody(event.target.value)} + ref={bodyInputRef} rows={3} value={body} /> @@ -820,6 +851,7 @@ function ReplyComposer({ disabled={!canAttach || publishing} files={attachments} onChange={setAttachments} + pasteTargetRef={bodyInputRef} />
{ }); describe('feedback attachment helpers', () => { + it('uses direct clipboard files first and supports item-only screenshots', () => { + const direct = { name: 'direct.png' } as File; + const screenshot = { name: 'clipboard.png' } as File; + + expect( + getTransferFiles({ + files: { 0: direct, length: 1 }, + items: { 0: { getAsFile: () => screenshot, kind: 'file' }, length: 1 }, + }), + ).toEqual([direct]); + expect( + getTransferFiles({ + files: { length: 0 }, + items: { 0: { getAsFile: () => screenshot, kind: 'file' }, length: 1 }, + }), + ).toEqual([screenshot]); + }); + + it('leaves text-only clipboard data to the textarea', () => { + expect( + getTransferFiles({ + files: { length: 0 }, + items: { 0: { getAsFile: () => null, kind: 'string' }, length: 1 }, + }), + ).toEqual([]); + }); + it('routes safe raster images and media to matching QDN services', () => { expect(getAttachmentService({ type: 'image/png' } as File)).toBe('IMAGE'); expect(getAttachmentService({ type: 'image/svg+xml' } as File)).toBe('ATTACHMENT'); diff --git a/src/attachmentUpload.ts b/src/attachmentUpload.ts index 81907a8..2f0f0a7 100644 --- a/src/attachmentUpload.ts +++ b/src/attachmentUpload.ts @@ -25,6 +25,16 @@ export type PreparedFeedbackAttachment = { size: number; }; +type TransferFileItem = { + getAsFile(): File | null; + kind: string; +}; + +type TransferFileSource = { + files?: ArrayLike | null; + items?: ArrayLike | null; +}; + export type PublishedQdnResource = { resource: { identifier: string | null; @@ -41,6 +51,37 @@ export type PublishMultipleResult = { published?: PublishedQdnResource[]; }; +/** + * Clipboard implementations vary: a copied screenshot can appear in the + * direct FileList, as a file-kind item, or both. Prefer the FileList so the + * browser does not hand the same file to the caller twice; fall back to items + * for screenshot tools that expose no FileList. Text and HTML are excluded so + * ordinary text paste stays with the textarea. + */ +export function getTransferFiles(source: TransferFileSource | null | undefined): File[] { + const directFiles = Array.from(source?.files ?? []); + + if (directFiles.length > 0) { + return directFiles; + } + + const files: File[] = []; + + for (const item of Array.from(source?.items ?? [])) { + if (item.kind !== 'file') { + continue; + } + + const file = item.getAsFile(); + + if (file) { + files.push(file); + } + } + + return files; +} + export function getAttachmentService(file: Pick): AttachmentService { const type = file.type.toLowerCase();