From 22ef71463fdb6789f1f2cdc5b05215a2fa046124 Mon Sep 17 00:00:00 2001 From: "Brian R. Bondy" Date: Fri, 5 Sep 2025 13:17:29 -0400 Subject: [PATCH] Allow PDF files to be drag and dropped (#31015) --- .../page/components/drag_overlay/index.tsx | 6 +- .../page/constants/file_types.test.ts | 44 +++++++++++++- .../resources/page/constants/file_types.ts | 5 ++ .../resources/page/utils/file_utils.test.ts | 54 ++++++++++++++++- .../resources/page/utils/file_utils.ts | 60 +++++++++++++------ 5 files changed, 145 insertions(+), 24 deletions(-) diff --git a/components/ai_chat/resources/page/components/drag_overlay/index.tsx b/components/ai_chat/resources/page/components/drag_overlay/index.tsx index 957781cb003..e25ac42a0a8 100644 --- a/components/ai_chat/resources/page/components/drag_overlay/index.tsx +++ b/components/ai_chat/resources/page/components/drag_overlay/index.tsx @@ -7,7 +7,7 @@ import * as React from 'react' import { getLocale } from '$web-common/locale' import Icon from '@brave/leo/react/icon' import styles from './style.module.scss' -import { isImageFile } from '../../constants/file_types' +import { isImageFile, isPdfFile } from '../../constants/file_types' import { useConversation } from '../../state/conversation_context' import { convertFileToUploadedFile } from '../../utils/file_utils' @@ -23,7 +23,9 @@ export default function DragOverlay() { e.stopPropagation() clearDragState() - const files = Array.from(e.dataTransfer?.files || []).filter(isImageFile) + const files = Array.from(e.dataTransfer?.files || []).filter(file => + isImageFile(file) || isPdfFile(file) + ) if (files.length === 0) { return diff --git a/components/ai_chat/resources/page/constants/file_types.test.ts b/components/ai_chat/resources/page/constants/file_types.test.ts index fcd2349c701..2d78ee3fbea 100644 --- a/components/ai_chat/resources/page/constants/file_types.test.ts +++ b/components/ai_chat/resources/page/constants/file_types.test.ts @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { isImageFile } from './file_types' +import { isImageFile, isPdfFile } from './file_types' describe('isImageFile', () => { // Helper function to create mock File objects @@ -114,3 +114,45 @@ describe('isImageFile', () => { }) }) }) + +describe('isPdfFile', () => { + // Helper function to create mock File objects + const createMockFile = (name: string, type: string): File => { + return new File([''], name, { type }) + } + + it('accepts PDF files', () => { + const file = createMockFile('test.pdf', 'application/pdf') + expect(isPdfFile(file)).toBe(true) + }) + + it('handles uppercase MIME type', () => { + const file = createMockFile('test.pdf', 'APPLICATION/PDF') + expect(isPdfFile(file)).toBe(true) + }) + + it('handles mixed case MIME type', () => { + const file = createMockFile('test.pdf', 'Application/Pdf') + expect(isPdfFile(file)).toBe(true) + }) + + it('rejects image files', () => { + const file = createMockFile('test.png', 'image/png') + expect(isPdfFile(file)).toBe(false) + }) + + it('rejects text files', () => { + const file = createMockFile('test.txt', 'text/plain') + expect(isPdfFile(file)).toBe(false) + }) + + it('rejects empty MIME type', () => { + const file = createMockFile('test', '') + expect(isPdfFile(file)).toBe(false) + }) + + it('rejects malformed MIME type', () => { + const file = createMockFile('test', 'not-a-mime-type') + expect(isPdfFile(file)).toBe(false) + }) +}) diff --git a/components/ai_chat/resources/page/constants/file_types.ts b/components/ai_chat/resources/page/constants/file_types.ts index 0b0047ed08a..d4f4f253483 100644 --- a/components/ai_chat/resources/page/constants/file_types.ts +++ b/components/ai_chat/resources/page/constants/file_types.ts @@ -14,3 +14,8 @@ const SUPPORTED_IMAGE_TYPES: string[] = [ export const isImageFile = (file: File): boolean => { return SUPPORTED_IMAGE_TYPES.includes(file.type.toLowerCase()) } + +// Check if file is a PDF +export const isPdfFile = (file: File): boolean => { + return file.type.toLowerCase() === 'application/pdf' +} diff --git a/components/ai_chat/resources/page/utils/file_utils.test.ts b/components/ai_chat/resources/page/utils/file_utils.test.ts index 407bc3d0c3b..2359f93053b 100644 --- a/components/ai_chat/resources/page/utils/file_utils.test.ts +++ b/components/ai_chat/resources/page/utils/file_utils.test.ts @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { convertFileToUploadedFile, FileReadError, ImageProcessingError } from './file_utils' +import { convertFileToUploadedFile, FileReadError, ImageProcessingError, UnsupportedFileTypeError } from './file_utils' import * as Mojom from '../../common/mojom' import getAPI from '../api' @@ -20,7 +20,6 @@ jest.mock('$web-common/loadTimeData', () => ({ } })) - describe('convertFileToUploadedFile', () => { let mockHandler: any let mockService: any @@ -97,7 +96,6 @@ describe('convertFileToUploadedFile', () => { return new File(['mock content'], name, { type, size: size }) } - describe('successful file processing', () => { it('converts image file successfully', async () => { const file = createMockFile('test.png', 'image/png', 1500) @@ -161,6 +159,32 @@ describe('convertFileToUploadedFile', () => { expect(result).toEqual(mockProcessedFile) }) + it('converts PDF file successfully', async () => { + const file = createMockFile('test.pdf', 'application/pdf', 2000) + const mockArrayBuffer = new ArrayBuffer(8) + const expectedData = Array.from(new Uint8Array(mockArrayBuffer)) + + // Override readAsArrayBuffer to trigger success + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + process.nextTick(() => { + if (mockFileReader.onload) { + mockFileReader.onload({ target: { result: mockArrayBuffer } }) + } + }) + }) + + const result = await convertFileToUploadedFile(file) + + expect(mockFileReader.readAsArrayBuffer).toHaveBeenCalledWith(file) + expect(mockHandler.processImageFile).not.toHaveBeenCalled() + expect(result).toEqual({ + filename: 'test.pdf', + filesize: file.size, // Use actual file size + data: expectedData, + type: Mojom.UploadedFileType.kPdf + }) + }) + it('throws ImageProcessingError for empty files', async () => { const file = createMockFile('empty.png', 'image/png', 0) const mockArrayBuffer = new ArrayBuffer(0) @@ -258,6 +282,30 @@ describe('convertFileToUploadedFile', () => { ) }) + it('throws UnsupportedFileTypeError for unknown file types', async () => { + const file = createMockFile('test.txt', 'text/plain') + const mockArrayBuffer = new ArrayBuffer(8) + + // Override readAsArrayBuffer to trigger success + // (FileReader will work, but file type check will fail) + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + process.nextTick(() => { + if (mockFileReader.onload) { + mockFileReader.onload({ target: { result: mockArrayBuffer } }) + } + }) + }) + + await expect(convertFileToUploadedFile(file)).rejects.toThrow( + UnsupportedFileTypeError + ) + await expect(convertFileToUploadedFile(file)).rejects.toThrow( + 'Unsupported file type: text/plain. Only images and PDF files are ' + + 'supported.' + ) + expect(mockHandler.processImageFile).not.toHaveBeenCalled() + }) + }) describe('data transformation', () => { diff --git a/components/ai_chat/resources/page/utils/file_utils.ts b/components/ai_chat/resources/page/utils/file_utils.ts index 9ba818e36c2..6ea211b0175 100644 --- a/components/ai_chat/resources/page/utils/file_utils.ts +++ b/components/ai_chat/resources/page/utils/file_utils.ts @@ -21,12 +21,17 @@ export class ImageProcessingError extends Error { } } +export class UnsupportedFileTypeError extends Error { + constructor(message: string) { + super(message) + this.name = 'UnsupportedFileTypeError' + } +} // Utility function to convert File objects to UploadedFile format export const convertFileToUploadedFile = async ( file: File ): Promise => { - // Use backend processing for images via mojo call const reader = new FileReader() const arrayBuffer = await new Promise((resolve, reject) => { reader.onload = (e) => { @@ -44,26 +49,45 @@ export const convertFileToUploadedFile = async ( const uint8Array = new Uint8Array(arrayBuffer) - try { - const api = getAPI() - const response = await api.uiHandler.processImageFile( - Array.from(uint8Array), - file.name - ) - - if (!response.processedFile) { - throw new ImageProcessingError( - 'Failed to process image file: Backend returned no result' - ) + // Check file type and handle accordingly + const mimeType = file.type.toLowerCase() + if (mimeType === 'application/pdf') { + // Handle PDF files directly + const uploadedFile: Mojom.UploadedFile = { + filename: file.name, + filesize: file.size, + data: Array.from(uint8Array), + type: Mojom.UploadedFileType.kPdf } + return uploadedFile + } else if (mimeType.startsWith('image/')) { + // Use backend processing for images via mojo call + try { + const api = getAPI() + const response = await api.uiHandler.processImageFile( + Array.from(uint8Array), + file.name + ) - return response.processedFile - } catch (error) { - if (error instanceof ImageProcessingError) { + if (!response.processedFile) { + throw new ImageProcessingError( + 'Failed to process image file: Backend returned no result' + ) + } + + return response.processedFile + } catch (error) { + if (error instanceof ImageProcessingError) { + throw error + } + + // Re-throw any other errors as-is throw error } - - // Re-throw any other errors as-is - throw error + } else { + throw new UnsupportedFileTypeError( + `Unsupported file type: ${file.type}. Only images and PDF files ` + + `are supported.` + ) } }