From 33c310a951d97dbf5aa44c38bc45306563ffac82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20La=20Scala?= Date: Mon, 3 Apr 2023 17:25:13 +0200 Subject: [PATCH] feat: implement file upload and download Some issues related to file download are left --- src/components/ExcalidrawView.tsx | 113 ++++++++-------------- src/components/context/AppDataContext.tsx | 39 +++++--- src/data/files.ts | 67 +++++++++++++ 3 files changed, 136 insertions(+), 83 deletions(-) create mode 100644 src/data/files.ts diff --git a/src/components/ExcalidrawView.tsx b/src/components/ExcalidrawView.tsx index d33cd886..66564150 100644 --- a/src/components/ExcalidrawView.tsx +++ b/src/components/ExcalidrawView.tsx @@ -9,13 +9,11 @@ import Box from '@mui/material/Box'; import { Excalidraw } from '@excalidraw/excalidraw'; import type { ExcalidrawElement, - FileId, + ExcalidrawImageElement, } from '@excalidraw/excalidraw/types/element/types'; import { AppState, - BinaryFileData, BinaryFiles, - DataURL, ExcalidrawImperativeAPI, } from '@excalidraw/excalidraw/types/types'; @@ -39,6 +37,7 @@ import { EXCALIDRAW_ENABLE_ZEN_MODE, EXCALIDRAW_THEME, } from '../config/settings'; +import { getListOfFileIds, useFiles } from '../data/files'; import { reconcileElements } from '../utils/reconciliation'; import Loader from './common/Loader'; import RefreshButton from './common/RefreshButton'; @@ -50,63 +49,26 @@ const ExcalidrawView: FC = () => { const date = new Date(); const name = `drawing${date.toISOString()}`; - const [localElements, setLocalElements] = - useState(); + const [localElements, setLocalElements] = useState< + readonly ExcalidrawElement[] + >([]); const [idElements, setIdElements] = useState(''); const [idState, setIdState] = useState(''); const [appState, setAppState] = useState(); const [filesAppData, setFilesAppData] = useState>(List()); + const files = useFiles(filesAppData); const { appDataArray, patchAppData, postAppData, - deleteAppData, uploadFile, - getFileContent, + deleteFile, status, } = useAppDataContext(); const { isLoading } = status; - const getFile = useCallback( - async (fileAppData: FileAppData): Promise => { - const { id, data } = fileAppData; - const { name: fileName, mimetype } = data.extra.s3File; - const fileData = await getFileContent(id || '').then((result) => { - console.log('fileData', result); - return result; - }); - const file: BinaryFileData = { - mimeType: mimetype as BinaryFileData['mimeType'], - id: fileName as FileId, - dataURL: URL.createObjectURL(fileData) as DataURL, - created: data?.created as number, - }; - - return file; - }, - [getFileContent], - ); - - const addAllFilesToExcalidraw = useCallback( - async (filesAppDataToAdd: List): Promise => { - const promisedFiles = filesAppDataToAdd.map(getFile); - const files: BinaryFileData[] = []; - promisedFiles.map((promisedFile) => - promisedFile.then((d) => files.push(d)), - ); - console.log('Files to be added: ', files); - excalidrawRef.current?.addFiles(files); - }, - [getFile], - ); - - useEffect(() => { - // Add all the files to excalidraw - addAllFilesToExcalidraw(filesAppData); - }, [addAllFilesToExcalidraw, filesAppData]); - useEffect(() => { const elementsAppData: ExcalidrawElementsAppData = (appDataArray.find( ({ type }) => type === APP_DATA_TYPES.EXCALIDRAW_ELEMENTS, @@ -143,7 +105,7 @@ const ExcalidrawView: FC = () => { elements: reconciledElements, commitToHistory: false, }); - setLocalElements(reconciledElements); + setLocalElements(reconciledElements as readonly ExcalidrawElement[]); } else { setLocalElements(newElements); } @@ -155,6 +117,10 @@ const ExcalidrawView: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [appDataArray]); + useEffect(() => { + excalidrawRef.current?.addFiles(files); + }, [files]); + const saveElements = ( elements: readonly ExcalidrawElement[], id: string, @@ -210,31 +176,31 @@ const ExcalidrawView: FC = () => { const compareAndSaveFiles = useCallback( async ( - files: BinaryFiles, - currentFileData: List, + filesLocal: BinaryFiles, + filesAppDataCurrent: List, + elements: ExcalidrawElement[], ): Promise => { - const listOfFiles = List(Object.values(files)); - console.log('Handling these files: ', listOfFiles); - console.log('With already these files: ', currentFileData); - - const listOfIds = listOfFiles.map(({ id }) => String(id)); - currentFileData - .filterNot(({ data: fileData }) => listOfIds.includes(fileData.name)) - .forEach(({ id }) => deleteAppData({ id })); - if (!listOfFiles.isEmpty()) { - const listOfNames = currentFileData.map( - ({ data: fileData }) => fileData.name, - ); - listOfFiles - .filterNot(({ id }) => listOfNames.includes(id)) - .forEach((file) => { - if (file.id) { - uploadFile(file); - } - }); - } + const listOfIds = getListOfFileIds( + elements.filter( + (element) => + Object.keys(element).includes('fileId') && + element?.isDeleted === false, + ) as ExcalidrawImageElement[], + ); + const listOfUploadedIds = filesAppDataCurrent.map( + ({ data }) => data.name, + ); + Object.values(filesLocal).forEach((file) => { + const isUsed = listOfIds.includes(file.id); + const isUploaded = listOfUploadedIds.includes(file.id); + if (isUsed && !isUploaded) { + uploadFile(file); + } else if (!isUsed && isUploaded) { + deleteFile(file.id); + } + }); }, - [deleteAppData, uploadFile], + [deleteFile, uploadFile], ); const debouncedCompareElements = useRef( @@ -252,7 +218,7 @@ const ExcalidrawView: FC = () => { const handleChange = ( elements: readonly ExcalidrawElement[], newAppState: AppState, - files: BinaryFiles, + filesLocal: BinaryFiles, ): void => { const { isResizing, @@ -264,12 +230,15 @@ const ExcalidrawView: FC = () => { typeof localElements !== 'undefined' ) { debouncedCompareElements(elements, localElements, idElements); - debouncedCompareAndSaveFiles(files, filesAppData); + if (!appState?.pendingImageElementId) { + debouncedCompareAndSaveFiles(filesLocal, filesAppData, [...elements]); + } debouncedSaveState(newAppState, idState); } else { debouncedCompareElements.cancel(); debouncedSaveElements.cancel(); debouncedSaveState.cancel(); + debouncedCompareAndSaveFiles.cancel(); } }; @@ -290,7 +259,7 @@ const ExcalidrawView: FC = () => { initialData={{ elements: localElements, appState, - files: filesAppData.map(getFile).toArray(), + files, }} ref={excalidrawRef} onChange={handleChange} diff --git a/src/components/context/AppDataContext.tsx b/src/components/context/AppDataContext.tsx index bfc71ad1..16a91ac2 100644 --- a/src/components/context/AppDataContext.tsx +++ b/src/components/context/AppDataContext.tsx @@ -58,7 +58,9 @@ export type AppDataContextType = { patchAppData: (payload: PatchAppDataType) => void; deleteAppData: (payload: DeleteAppDataType) => void; uploadFile: (fileToUpload: FileToUploadType) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any getFileContent: (fileId: string) => Promise; + deleteFile: (fileId: string) => Promise; appDataArray: List; status: { isLoading: boolean; isFetching: boolean; isPreviousData: boolean }; }; @@ -69,8 +71,10 @@ const defaultContextValue = { deleteAppData: () => null, // eslint-disable-next-line @typescript-eslint/no-empty-function uploadFile: () => new Promise(() => {}), // Maybe there is a better way to write this? - // eslint-disable-next-line @typescript-eslint/no-empty-function + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-explicit-any getFileContent: () => new Promise(() => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + deleteFile: () => new Promise(() => {}), appDataArray: List(), status: { isFetching: false, @@ -109,7 +113,8 @@ export const AppDataProvider: FC = ({ children }) => { const uploadFile = useCallback( async (fileToUpload: FileToUploadType): Promise => { - const { mimeType, id, dataURL } = fileToUpload; + // console.log('Uploading: ', fileToUpload); + const { mimeType, id, dataURL, created } = fileToUpload; const xhr = new XMLHttpRequest(); xhr.open( 'POST', @@ -123,32 +128,42 @@ export const AppDataProvider: FC = ({ children }) => { formData.append(id, file, id); xhr.onreadystatechange = () => { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { - console.log(`File ${id} of type ${mimeType} successfully sent!`); + // console.log(`File ${id} of type ${mimeType} successfully sent!`); onFileUploadComplete({ id: itemId, - data: xhr.response?.body?.[0].filter(Boolean), + data: { ...xhr.response?.body?.[0].filter(Boolean), created }, }); } }; - console.log(`Sending ${id} of type ${mimeType}...`); + // console.log(`Sending ${id} of type ${mimeType}...`); xhr.send(formData); }, [itemId, onFileUploadComplete, token], ); const getFileContent = useCallback( - (fileId: string): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (fileId: string): Promise => { const content = Api.getFileContent({ id: fileId, apiHost: REACT_APP_API_HOST, token: token || '', }).then((contentData) => contentData); - console.log('File content: ', content); return content; }, [token], ); + const deleteFile = useCallback( + async (fileId: string) => { + const fileAppData = data?.find(({ data: file }) => file?.name === fileId); + if (fileAppData) { + deleteAppData(fileAppData); + } + }, + [data, deleteAppData], + ); + const contextValue: AppDataContextType = useMemo( () => ({ postAppData: (payload: PostAppDataType) => { @@ -159,6 +174,7 @@ export const AppDataProvider: FC = ({ children }) => { appDataArray: data || List(), uploadFile, getFileContent, + deleteFile, status: { isFetching, isLoading, @@ -166,15 +182,16 @@ export const AppDataProvider: FC = ({ children }) => { }, }), [ - data, + patchAppData, deleteAppData, + data, + uploadFile, + getFileContent, + deleteFile, isFetching, isLoading, isPreviousData, - patchAppData, postAppData, - uploadFile, - getFileContent, ], ); diff --git a/src/data/files.ts b/src/data/files.ts new file mode 100644 index 00000000..2530c5d3 --- /dev/null +++ b/src/data/files.ts @@ -0,0 +1,67 @@ +import { List } from 'immutable'; + +import { useCallback, useEffect, useState } from 'react'; + +import { + ExcalidrawImageElement, + FileId, +} from '@excalidraw/excalidraw/types/element/types'; +import { BinaryFileData, DataURL } from '@excalidraw/excalidraw/types/types'; + +import { useAppDataContext } from '../components/context/AppDataContext'; +import { FileAppData } from '../config/appDataTypes'; + +export const getListOfFileIds = ( + elements: ExcalidrawImageElement[], +): List => + List(elements).map((element) => element?.fileId as string); + +export const useFiles = (filesAppData: List): BinaryFileData[] => { + const [files, setFiles] = useState([]); + const { getFileContent } = useAppDataContext(); + const getFile = useCallback( + async (fileAppData: FileAppData): Promise => { + const { id, data: fileAppDataData } = fileAppData; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reader = (file: any): Promise => + new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(fr.result as DataURL); + fr.onerror = (err) => reject(err); + fr.readAsDataURL(file); + }); + const { name: fileName, mimetype } = fileAppDataData.extra.s3File; + const fileData = await getFileContent(id || '').then((result) => + reader(result), + ); + const file: BinaryFileData = { + mimeType: mimetype as BinaryFileData['mimeType'], + id: fileName as FileId, + dataURL: fileData, + created: Date.now(), + }; + + return file; + }, + [getFileContent], + ); + + const getFiles = useCallback( + (filesAppDataToAdd: List): Promise => { + const promisedFiles = filesAppDataToAdd.map(getFile); + return Promise.all(promisedFiles).then((result) => result); + }, + [getFile], + ); + + useEffect(() => { + const filesToDownload = filesAppData.filterNot(({ data }) => + files.map(({ id }) => id as string).includes(data.name), + ); + if (!filesToDownload.isEmpty()) { + getFiles(filesToDownload).then((binaryFiles) => setFiles(binaryFiles)); + } + }, [files, filesAppData, getFiles]); + + return files; +};