From 7caa5bce25e9bcf231b50302363554725678413a Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Sat, 21 Oct 2023 05:29:32 +0530 Subject: [PATCH] [MM-54486] Copy pasting images from chrome fails (#24718) --- .../file_upload/file_upload.test.tsx | 9 +- .../components/file_upload/file_upload.tsx | 67 +++------ webapp/channels/src/utils/paste.test.tsx | 130 +++++++++++++++++- webapp/channels/src/utils/paste.tsx | 44 ++++++ 4 files changed, 199 insertions(+), 51 deletions(-) diff --git a/webapp/channels/src/components/file_upload/file_upload.test.tsx b/webapp/channels/src/components/file_upload/file_upload.test.tsx index 2824c6a7f7..035b316a8f 100644 --- a/webapp/channels/src/components/file_upload/file_upload.test.tsx +++ b/webapp/channels/src/components/file_upload/file_upload.test.tsx @@ -8,8 +8,7 @@ import type {FileInfo} from '@mattermost/types/files'; import {General} from 'mattermost-redux/constants'; -import FileUpload from 'components/file_upload/file_upload'; -import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; +import FileUpload, {type FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; import {clearFileInput} from 'utils/utils'; @@ -232,7 +231,11 @@ describe('components/FileUpload', () => { const event = new Event('paste'); event.preventDefault = jest.fn(); const getAsString = jest.fn(); - (event as any).clipboardData = {items: [{getAsString, kind: 'string', type: 'text/plain'}], types: ['text/plain'], getData: () => {}}; + (event as any).clipboardData = {items: [{getAsString, kind: 'string', type: 'text/plain'}], + types: ['text/plain'], + getData: () => { + return ''; + }}; const wrapper = shallowWithIntl( { containsEventTarget = (targetElement: HTMLInputElement | null, eventTarget: EventTarget | null) => targetElement && targetElement.contains(eventTarget as Node); + /** + * This paste handler sole responsibility is to detect if the clipboard data contains "files" and pass them to the upload file handler. + */ pasteUpload = (e: ClipboardEvent) => { - const {formatMessage} = this.props.intl; - - if (!e.clipboardData || !e.clipboardData.items || e.clipboardData.getData('text/html')) { + // If the clipboard data doesn't contain anything or it contains plain text, do nothing and let the browser and other handlers do their thing. + if (!e.clipboardData || !e.clipboardData.items || hasPlainText(e.clipboardData)) { return; } @@ -461,53 +460,28 @@ export class FileUpload extends PureComponent { this.props.onUploadError(null); - const items = []; - for (let i = 0; i < e.clipboardData.items.length; i++) { - const item = e.clipboardData.items[i]; + const fileClipboardItems = Array. + from(e.clipboardData.items). + filter((item) => item.kind === 'file'); - if (item.kind !== 'file') { - continue; - } - - items.push(item); - } - - if (items && items.length > 0) { + if (fileClipboardItems.length > 0) { if (!this.props.canUploadFiles) { - this.props.onUploadError(localizeMessage('file_upload.disabled', 'File attachments are disabled.')); + this.props.onUploadError(this.props.intl.formatMessage({id: 'file_upload.disabled', defaultMessage: 'File attachments are disabled.'})); return; } - e.preventDefault(); + const fileNamePrefixIfNoName = this.props.intl.formatMessage({id: 'file_upload.pasted', defaultMessage: 'Image Pasted at '}); - const files = []; + const fileList = fileClipboardItems. + map((fileClipboardItem) => createFileFromClipboardDataItem(fileClipboardItem, fileNamePrefixIfNoName)). + filter((file): file is NonNullable => file !== null); - for (let i = 0; i < items.length; i++) { - const file = items[i].getAsFile(); + if (fileList.length > 0) { + // Prevent default will stop event propagation to other handlers such as those in advanced text editor + // so we do that here because we want to only paste the files from the clipboard and not other content. + e.preventDefault(); - if (!file) { - continue; - } - - const now = new Date(); - const hour = now.getHours().toString().padStart(2, '0'); - const minute = now.getMinutes().toString().padStart(2, '0'); - - let ext = ''; - if (file.name && file.name.includes('.')) { - ext = file.name.substr(file.name.lastIndexOf('.')); - } else if (items[i].type.includes('/')) { - ext = '.' + items[i].type.split('/')[1].toLowerCase(); - } - - const name = file.name || formatMessage(holders.pasted) + now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate() + ' ' + hour + '-' + minute + ext; - - const newFile: File = new File([file], name, {type: file.type}); - files.push(newFile); - } - - if (files.length > 0) { - this.checkPluginHooksAndUploadFiles(files); + this.checkPluginHooksAndUploadFiles(fileList); this.props.onFileUploadChange(); } } @@ -735,7 +709,6 @@ export class FileUpload extends PureComponent { } return ( -
{bodyAction}
diff --git a/webapp/channels/src/utils/paste.test.tsx b/webapp/channels/src/utils/paste.test.tsx index 253808e364..85ce4b6a5f 100644 --- a/webapp/channels/src/utils/paste.test.tsx +++ b/webapp/channels/src/utils/paste.test.tsx @@ -1,7 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {parseHtmlTable, getHtmlTable, formatMarkdownMessage, formatGithubCodePaste, formatMarkdownLinkMessage, isTextUrl} from './paste'; +import { + parseHtmlTable, + getHtmlTable, + formatMarkdownMessage, + formatGithubCodePaste, + formatMarkdownLinkMessage, + isTextUrl, + hasPlainText, + createFileFromClipboardDataItem, +} from './paste'; const validClipboardData: any = { items: [1], @@ -221,3 +230,122 @@ describe('isTextUrl', () => { expect(isTextUrl(clipboardData)).toBe(false); }); }); + +describe('hasPlainText', () => { + test('Should return true when clipboard data has plain text', () => { + const clipboardData = { + ...validClipboardData, + types: ['text/plain'], + getData: () => { + return 'plain text'; + }, + }; + + expect(hasPlainText(clipboardData)).toBe(true); + }); + + test('Should return true when clipboard data has plain text along with other types', () => { + const clipboardData = { + ...validClipboardData, + types: ['text/html', 'text/plain'], + getData: () => { + return 'plain text'; + }, + }; + + expect(hasPlainText(clipboardData)).toBe(true); + }); + + test('Should return false when clipboard data has empty text', () => { + const clipboardData = { + ...validClipboardData, + types: ['text/html', 'text/plain'], + getData: () => { + return ''; + }, + }; + + expect(hasPlainText(clipboardData)).toBe(false); + }); + + test('Should return false when clipboard data doesnt not have plain text type', () => { + const clipboardData = { + ...validClipboardData, + types: ['text/html'], + getData: () => { + return 'plain text without type'; + }, + }; + + expect(hasPlainText(clipboardData)).toBe(false); + }); +}); + +describe('createFileFromClipboardDataItem', () => { + test('should return a file from a clipboard item', () => { + const item = { + getAsFile: jest.fn(() => ({ + name: 'test1.png', + type: 'image/png', + })), + type: 'image/png', + } as unknown as DataTransferItem; + + const file = createFileFromClipboardDataItem(item, '') as File; + expect(file).toBeInstanceOf(File); + expect(file.name).toEqual('test1.png'); + expect(file.type).toEqual('image/png'); + }); + + test('should return null if getAsFile is not a file', () => { + const item = { + getAsFile: jest.fn(() => null), + } as unknown as DataTransferItem; + + const file = createFileFromClipboardDataItem(item, ''); + expect(file).toBeNull(); + }); + + test('Should return correct file name when file name is not available', () => { + const item = { + getAsFile: jest.fn(() => ({ + type: 'image/jpeg', + })), + type: 'image/jpeg', + } as unknown as DataTransferItem; + + const now = new Date(); + + const file = createFileFromClipboardDataItem(item, 'pasted') as File; + + expect(file).toBeInstanceOf(File); + expect(file.name).toBe(`pasted${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} ${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}.jpeg`); + expect(file.type).toBe('image/jpeg'); + }); + + test('Should return correct file extension when file name contains extension', () => { + const item = { + getAsFile: jest.fn(() => ({ + name: 'test.jpeg', + })), + type: 'image/jpeg', + } as unknown as DataTransferItem; + + const file = createFileFromClipboardDataItem(item, 'pasted') as File; + + expect(file.name).toContain('.jpeg'); + }); + + test('Should return correct file extension when file name doesnt contains extension', () => { + const item = { + getAsFile: jest.fn(() => ({ + type: 'image/JPEG', + })), + type: 'image/jpeg', + } as unknown as DataTransferItem; + + const file = createFileFromClipboardDataItem(item, 'pasted') as File; + + expect(file.name).toContain('.jpeg'); + }); +}); diff --git a/webapp/channels/src/utils/paste.tsx b/webapp/channels/src/utils/paste.tsx index d254ce79bf..27bb150689 100644 --- a/webapp/channels/src/utils/paste.tsx +++ b/webapp/channels/src/utils/paste.tsx @@ -10,6 +10,7 @@ export function parseHtmlTable(html: string): HTMLTableElement | null { } export function getHtmlTable(clipboardData: DataTransfer): HTMLTableElement | null { + // Check if clipboard data has html as one of its types if (Array.from(clipboardData.types).indexOf('text/html') === -1) { return null; } @@ -42,6 +43,18 @@ export function isTextUrl(clipboardData: DataTransfer): boolean { return clipboardText.startsWith('http://') || clipboardText.startsWith('https://'); } +/** + * Checks if the clipboard data contains plain text from list of types. +**/ +export function hasPlainText(clipboardData: DataTransfer): boolean { + if (Array.from(clipboardData.types).includes('text/plain')) { + const clipboardText = clipboardData.getData('text/plain'); + + return clipboardText.trim().length > 0; + } + return false; +} + function isTableWithoutHeaderRow(table: HTMLTableElement): boolean { return table.querySelectorAll('th').length === 0; } @@ -140,3 +153,34 @@ export function formatMarkdownLinkMessage({message, clipboardData, selectionStar const markdownLink = `[${selectedText}](${clipboardUrl})`; return markdownLink; } + +export function createFileFromClipboardDataItem(item: DataTransferItem, fileNamePrefixIfNoName: string): File | null { + const file = item.getAsFile(); + + if (!file) { + return null; + } + + let ext = ''; + if (file.name && file.name.includes('.')) { + ext = file.name.slice(file.name.lastIndexOf('.')); + } else if (item.type.includes('/')) { + ext = '.' + item.type.slice(item.type.lastIndexOf('/') + 1).toLowerCase(); + } + + let name = ''; + if (file.name) { + name = file.name; + } else { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const date = now.getDate(); + const hour = now.getHours().toString().padStart(2, '0'); + const minute = now.getMinutes().toString().padStart(2, '0'); + + name = `${fileNamePrefixIfNoName}${year}-${month}-${date} ${hour}-${minute}${ext}`; + } + + return new File([file as Blob], name, {type: file.type}); +}