Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow passthrough of accepted image file types to display in image upload UI #304

Merged
merged 8 commits into from
Apr 24, 2024
7 changes: 5 additions & 2 deletions src/shared/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 49 additions & 4 deletions src/shared/prosemirror-plugins/image-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -84,6 +90,8 @@ export async function defaultImageUploadHandler(file: File): Promise<string> {
return json.UploadedImage;
}

const defaultAcceptedFileTypes = ["image/jpeg", "image/png", "image/gif"];

enum ValidationResult {
Ok,
FileTooLarge,
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -148,7 +157,7 @@ export class ImageUploader extends PluginInterfaceView<
<div class="fs-body2 p12 pb0 js-cta-container">
<label for="${this.uploadField.id}" class="d-inline-flex f:outline-ring s-link js-browse-button" aria-controls="image-preview-${randomId}">
Browse
</label>, drag & drop<span class="js-external-url-trigger-container d-none">, <button type="button" class="s-btn s-btn__link js-external-url-trigger">enter a link</button></span>, or paste an image <span class="fc-light fs-caption">Max size 2 MiB</span>
</label>, drag & drop<span class="js-external-url-trigger-container d-none">, <button type="button" class="s-btn s-btn__link js-external-url-trigger">enter a link</button></span>, or paste an image.
</div>

<div class="js-external-url-input-container p12 d-none">
Expand All @@ -175,6 +184,21 @@ export class ImageUploader extends PluginInterfaceView<
</div>
`;

// 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`)
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions test/shared/prosemirror-plugins/image-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading