diff --git a/src/shared/localization.ts b/src/shared/localization.ts index d2550cb5..6218087b 100644 --- a/src/shared/localization.ts +++ b/src/shared/localization.ts @@ -86,8 +86,11 @@ export const defaultStrings = { "Your image is too large to upload (over 2 MiB)" as string, upload_error_generic: "Image upload failed. Please try again." as string, - upload_error_unsupported_format: - "Please select an image (jpeg, png, gif) to upload" as string, + upload_error_unsupported_format: ({ + supportedFormats, + }: { + supportedFormats: string; + }) => `Please select an image (${supportedFormats}) to upload`, uploaded_image_preview_alt: "uploaded image preview" as string, }, } as const; diff --git a/src/shared/prosemirror-plugins/image-upload.ts b/src/shared/prosemirror-plugins/image-upload.ts index f1acd3be..71f90ab2 100644 --- a/src/shared/prosemirror-plugins/image-upload.ts +++ b/src/shared/prosemirror-plugins/image-upload.ts @@ -57,6 +57,12 @@ export interface ImageUploadOptions { * If true, allow users to add images via an external url */ allowExternalUrls?: boolean; + /** + * An array of strings containing the accepted file types for the image uploader. + * See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types for appropriate image + * file types. + */ + acceptedFileTypes?: string[]; } /** @@ -84,6 +90,8 @@ export async function defaultImageUploadHandler(file: File): Promise { return json.UploadedImage; } +const defaultAcceptedFileTypes = ["image/jpeg", "image/png", "image/gif"]; + enum ValidationResult { Ok, FileTooLarge, @@ -125,6 +133,7 @@ export class ImageUploader extends PluginInterfaceView< super(INTERFACE_KEY); const randomId = generateRandomId(); + const acceptedFileTypes = uploadOptions.acceptedFileTypes || []; this.isVisible = false; this.uploadOptions = uploadOptions; this.validateLink = validateLink; @@ -137,7 +146,7 @@ export class ImageUploader extends PluginInterfaceView< this.uploadField = document.createElement("input"); this.uploadField.type = "file"; this.uploadField.className = "js-image-uploader-input v-visible-sr"; - this.uploadField.accept = "image/*"; + this.uploadField.accept = acceptedFileTypes?.join(", "); this.uploadField.multiple = false; this.uploadField.id = "fileUpload" + randomId; @@ -148,7 +157,7 @@ export class ImageUploader extends PluginInterfaceView<
, drag & drop, , or paste an image Max size 2 MiB + , drag & drop, , or paste an image.
@@ -175,6 +184,21 @@ export class ImageUploader extends PluginInterfaceView<
`; + // add the caption element to the cta container + const ctaContainer = + this.uploadContainer.querySelector(".js-cta-container"); + const acceptedFileTypesString = acceptedFileTypes?.length + ? acceptedFileTypes.join(", ").replace(/image\//g, "") + : ""; + + if (acceptedFileTypesString) { + const breakEl = document.createElement("br"); + ctaContainer.appendChild(breakEl); + } + ctaContainer.appendChild( + this.getCaptionElement(acceptedFileTypesString) + ); + // add in the uploadField right after the first child element this.uploadContainer .querySelector(`.js-browse-button`) @@ -286,6 +310,19 @@ export class ImageUploader extends PluginInterfaceView< event.stopPropagation(); } + getCaptionElement(text: string): HTMLElement { + const uploadCaptionEl = document.createElement("span"); + uploadCaptionEl.className = "fc-light fs-caption"; + + let captionText = "(Max size 2 MiB)"; + if (text) { + captionText = `Supported file types: ${text} ${captionText}`; + } + uploadCaptionEl.innerText = captionText; + + return uploadCaptionEl; + } + handleFileSelection(view: EditorView): void { this.resetImagePreview(); const files = this.uploadField.files; @@ -311,7 +348,8 @@ export class ImageUploader extends PluginInterfaceView< } validateImage(image: File): ValidationResult { - const validTypes = ["image/jpeg", "image/png", "image/gif"]; + const validTypes = + this.uploadOptions.acceptedFileTypes ?? defaultAcceptedFileTypes; const sizeLimit = 0x200000; // 2 MiB if (validTypes.indexOf(image.type) === -1) { @@ -385,7 +423,14 @@ export class ImageUploader extends PluginInterfaceView< return; case ValidationResult.InvalidFileType: this.showValidationError( - _t("image_upload.upload_error_unsupported_format") + _t("image_upload.upload_error_unsupported_format", { + supportedFormats: ( + this.uploadOptions.acceptedFileTypes || + defaultAcceptedFileTypes + ) + .join(", ") + .replace(/image\//g, ""), + }) ); reject("invalid filetype"); return; diff --git a/test/shared/prosemirror-plugins/image-upload.test.ts b/test/shared/prosemirror-plugins/image-upload.test.ts index bfd2b635..ab2997f5 100644 --- a/test/shared/prosemirror-plugins/image-upload.test.ts +++ b/test/shared/prosemirror-plugins/image-upload.test.ts @@ -151,6 +151,47 @@ describe("image upload plugin", () => { expect(validationMessage.classList).not.toContain("d-none"); }); + it("should accept files that match those provided in the acceptedFileTypes option", async () => { + setupTestVariables({ + acceptedFileTypes: ["image/bmp"], + }); + + showImageUploader(view.editorView); + + await expect( + uploader.showImagePreview( + mockFile("some bmp file", "image/bmp") + ) + ).resolves.toBeUndefined(); + expect(findPreviewElement(uploader).classList).not.toContain( + "d-none" + ); + expect(findAddButton(uploader).disabled).toBe(false); + const validationMessage = findValidationMessage(uploader); + expect(validationMessage.classList).toContain("d-none"); + }); + + it("should reject unaccepted files when acceptedFileTypes option is defined", async () => { + setupTestVariables({ + acceptedFileTypes: ["image/bmp"], + }); + + showImageUploader(view.editorView); + + await expect( + uploader.showImagePreview( + mockFile("some gif file", "image/gif") + ) + ).rejects.toBe("invalid filetype"); + expect(findPreviewElement(uploader).classList).toContain("d-none"); + expect(findAddButton(uploader).disabled).toBe(true); + const validationMessage = findValidationMessage(uploader); + expect(validationMessage.textContent).toBe( + "Please select an image (bmp) to upload" + ); + expect(validationMessage.classList).not.toContain("d-none"); + }); + it("should hide error when hiding uploader", async () => { showImageUploader(view.editorView);