From fb882e2e2a1432003a28c1453ec1262fd385658e Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Mon, 13 Feb 2023 13:38:19 +0000 Subject: [PATCH] Updated koenig-lexical upload function to handle multiple file types refs https://github.com/TryGhost/Koenig/pull/491 - `` now takes a `fileUpoader` object in place of `imageUploadFunction` - updated the upload functions in `koenig-lexical-editor.js` to match expected patterns, handle multiple files and file types, and return expected upload progress, result, and error details --- .../app/components/koenig-lexical-editor.js | 244 +++++++++++++++--- 1 file changed, 214 insertions(+), 30 deletions(-) diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js index c15d9a1911..d176222c52 100644 --- a/ghost/admin/app/components/koenig-lexical-editor.js +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -4,6 +4,34 @@ import React, {Suspense} from 'react'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; import {action} from '@ember/object'; import {inject} from 'ghost-admin/decorators/inject'; +import {inject as service} from '@ember/service'; + +export const fileTypes = { + image: { + mimeTypes: ['image/gif', 'image/jpg', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp'], + extensions: ['gif', 'jpg', 'jpeg', 'png', 'svg', 'svgz', 'webp'], + endpoint: '/images/upload/', + resourceName: 'images' + }, + video: { + mimeTypes: ['video/mp4', 'video/webm', 'video/ogg'], + extensions: ['mp4', 'webm', 'ogv'], + endpoint: '/media/upload/', + resourceName: 'media' + }, + audio: { + mimeTypes: ['audio/mp3', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/vnd.wav', 'audio/wave', 'audio/x-wav', 'audio/mp4', 'audio/x-m4a'], + extensions: ['mp3', 'wav', 'ogg', 'm4a'], + endpoint: '/media/upload/', + resourceName: 'media' + }, + mediaThumbnail: { + mimeTypes: ['image/gif', 'image/jpg', 'image/jpeg', 'image/png', 'image/webp'], + extensions: ['gif', 'jpg', 'jpeg', 'png', 'webp'], + endpoint: '/media/thumbnail/upload/', + resourceName: 'images' + } +}; class ErrorHandler extends React.Component { state = { @@ -90,6 +118,8 @@ const KoenigEditor = (props) => { }; export default class KoenigLexicalEditor extends Component { + @service ajax; + @inject config; @action @@ -121,39 +151,193 @@ export default class KoenigLexicalEditor extends Component { } }; - const [uploadProgress, setUploadProgress] = React.useState(0); + const useFileUpload = (type = 'image', {formData = {}, requestMethod = 'post'} = {}) => { + const [progress, setProgress] = React.useState(0); + const [isLoading, setLoading] = React.useState(false); + const [errors, setErrors] = React.useState([]); + const [filesNumber, setFilesNumber] = React.useState(0); - const uploadProgressHandler = (event) => { - const percentComplete = (event.loaded / event.total) * 100; - setUploadProgress(percentComplete); - if (percentComplete === 100) { - setUploadProgress(0); + const progressTracker = React.useRef(new Map()); + + function updateProgress() { + if (progressTracker.current.size === 0) { + setProgress(0); + return; + } + + let totalProgress = 0; + + progressTracker.current.forEach(value => totalProgress += value); + + setProgress(Math.round(totalProgress / progressTracker.current.size)); } + + // we only check the file extension by default because IE doesn't always + // expose the mime-type, we'll rely on the API for final validation + function defaultValidator(file) { + let extensions = fileTypes[type].extensions; + let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); + + // if extensions is falsy exit early and accept all files + if (!extensions) { + return true; + } + + if (!Array.isArray(extensions)) { + extensions = extensions.split(','); + } + + if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) { + let validExtensions = `.${extensions.join(', .').toUpperCase()}`; + return `The file type you uploaded is not supported. Please use ${validExtensions}`; + } + + return true; + } + + const validate = (files = []) => { + const validationResult = []; + + for (let i = 0; i < files.length; i += 1) { + let file = files[i]; + let result = defaultValidator(file); + if (result === true) { + continue; + } + + validationResult.push({fileName: file.name, message: result}); + } + + return validationResult; + }; + + const _uploadFile = async (file) => { + progressTracker.current[file] = 0; + + const fileFormData = new FormData(); + fileFormData.append('file', file, file.name); + + Object.keys(formData || {}).forEach((key) => { + fileFormData.append(key, formData[key]); + }); + + const url = `${ghostPaths().apiRoot}${fileTypes[type].endpoint}`; + + try { + const response = await this.ajax[requestMethod](url, { + data: fileFormData, + processData: false, + contentType: false, + dataType: 'text', + xhr: () => { + const xhr = new window.XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + progressTracker.current.set(file, (event.loaded / event.total) * 100); + updateProgress(); + } + }, false); + + return xhr; + } + }); + + // force tracker progress to 100% in case we didn't get a final event + progressTracker.current.set(file, 100); + updateProgress(); + + let uploadResponse; + let responseUrl; + + try { + uploadResponse = JSON.parse(response); + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + } + + if (uploadResponse) { + const resource = uploadResponse[fileTypes[type].resourceName]; + if (resource && Array.isArray(resource) && resource[0]) { + responseUrl = resource[0].url; + } + } + + return { + url: responseUrl, + fileName: file.name + }; + } catch (error) { + console.error(error); // eslint-disable-line + + // grab custom error message if present + let message = error.payload?.errors?.[0]?.message || ''; + let context = error.payload?.errors?.[0]?.context || ''; + + // fall back to EmberData/ember-ajax default message for error type + if (!message) { + message = error.message; + } + + const errorResult = { + message, + context, + fileName: file.name + }; + + // TODO: check for or expose known error types? + setErrors([...errors, errorResult]); + } + }; + + const upload = async (files = []) => { + setFilesNumber(files.length); + setLoading(true); + + const validationResult = validate(files); + + if (validationResult.length) { + setErrors(validationResult); + setLoading(false); + setProgress(100); + + return null; + } + + const uploadPromises = []; + + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + uploadPromises.push(_uploadFile(file)); + } + + try { + const uploadResult = await Promise.all(uploadPromises); + setProgress(100); + progressTracker.current.clear(); + + setLoading(false); + + setErrors([]); // components expect array of objects: { fileName: string, message: string }[] + + return uploadResult; + } catch (error) { + console.error(error); // eslint-disable-line no-console + + setErrors([...errors, {message: error.message}]); + setLoading(false); + setProgress(100); + progressTracker.current.clear(); + + return null; + } + }; + + return {progress, isLoading, upload, errors, filesNumber}; }; - async function imageUploader(files) { - function uploadToUrl(formData, url) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', url); - xhr.upload.onprogress = (event) => { - uploadProgressHandler(event); - }; - xhr.onload = () => resolve(xhr.response); - xhr.onerror = () => reject(xhr.statusText); - xhr.send(formData); - }); - } - const formData = new FormData(); - formData.append('file', files[0]); - const url = `${ghostPaths().apiRoot}/images/upload/`; - const response = await uploadToUrl(formData, url); - const dataset = JSON.parse(response); - const imageUrl = dataset?.images?.[0].url; - return { - src: imageUrl - }; - } return (
@@ -162,7 +346,7 @@ export default class KoenigLexicalEditor extends Component { cardConfig={cardConfig} initialEditorState={this.args.lexical} onError={this.onError} - imageUploadFunction={{imageUploader, uploadProgress}} + fileUploader={{useFileUpload, fileTypes}} >