diff --git a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx index 18504da0acb..9539d1a9187 100644 --- a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx +++ b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx @@ -17,6 +17,7 @@ import type { GitHubViewer } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { extractImageFilesFromDataTransfer, + hasAttachableFeedbackImage, readFeedbackImageFiles, releaseFeedbackImageDraft, type FeedbackImageDraft @@ -258,10 +259,16 @@ export function SidebarFeedbackDialog({ // screenshot lands whether or not the caret is in the message box. onPaste={(event) => { const pasted = extractImageFilesFromDataTransfer(event.clipboardData) - if (pasted.length > 0) { - event.preventDefault() - handleAddFiles(pasted) + if (pasted.length === 0) { + return } + // Why: consume the paste only when something is actually attachable. + // An unsupported image still routes through for its rejection toast, + // but preventing default there would silently eat co-pasted text. + if (hasAttachableFeedbackImage(pasted)) { + event.preventDefault() + } + handleAddFiles(pasted) }} // Why: dragenter/leave fire per nested child; the hook counts depth so // the highlight only clears once the pointer leaves the dialog. diff --git a/src/renderer/src/lib/feedback-image-attachments.test.ts b/src/renderer/src/lib/feedback-image-attachments.test.ts index 681927e5962..ad979fe8843 100644 --- a/src/renderer/src/lib/feedback-image-attachments.test.ts +++ b/src/renderer/src/lib/feedback-image-attachments.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_FEEDBACK_IMAGE_BYTES, MAX_FEEDBACK_IMAGE_COUNT, + hasAttachableFeedbackImage, readFeedbackImageFiles } from './feedback-image-attachments' @@ -19,6 +20,26 @@ function pngFile(name: string, size = 8): File { return file } +describe('hasAttachableFeedbackImage', () => { + it('is true when any file is an allow-listed type', () => { + const svg = new File(['x'], 'a.svg', { type: 'image/svg+xml' }) + expect(hasAttachableFeedbackImage([svg, pngFile('a.png')])).toBe(true) + }) + + // Why: the paste handler only consumes the event when this is true. An + // image/* type outside the allow-list must still fall through so co-pasted + // text is not swallowed, while readFeedbackImageFiles raises its toast. + it('is false when every file is an unsupported image type', () => { + const svg = new File(['x'], 'a.svg', { type: 'image/svg+xml' }) + const bmp = new File(['x'], 'a.bmp', { type: 'image/bmp' }) + expect(hasAttachableFeedbackImage([svg, bmp])).toBe(false) + }) + + it('is false for an empty selection', () => { + expect(hasAttachableFeedbackImage([])).toBe(false) + }) +}) + describe('readFeedbackImageFiles', () => { it('reads supported images into drafts with distinct ids', async () => { const { images, errors } = await readFeedbackImageFiles([pngFile('a.png'), pngFile('a.png')], 0) diff --git a/src/renderer/src/lib/feedback-image-attachments.ts b/src/renderer/src/lib/feedback-image-attachments.ts index 8bfe9fe2a77..67e7e0fcad1 100644 --- a/src/renderer/src/lib/feedback-image-attachments.ts +++ b/src/renderer/src/lib/feedback-image-attachments.ts @@ -25,6 +25,16 @@ function isSupportedType(contentType: string): boolean { return (SUPPORTED_FEEDBACK_IMAGE_TYPES as readonly string[]).includes(contentType) } +/** + * Whether a paste should be consumed. Extraction stays broad so unsupported + * image types still reach the rejection toast, but swallowing the paste when + * nothing is attachable would also discard any text riding along on the + * clipboard. + */ +export function hasAttachableFeedbackImage(files: readonly File[]): boolean { + return files.some((file) => isSupportedType(file.type)) +} + export function releaseFeedbackImageDraft(draft: FeedbackImageDraft): void { URL.revokeObjectURL(draft.previewUrl) }