jb-image-input
Version:
image input web component
512 lines (506 loc) • 17.4 kB
text/typescript
import { ShowValidationErrorParameters, ValidationHelper, type ValidationItem, type ValidationResult, type WithValidation } from "jb-validation";
import type { JBFormInputStandards } from 'jb-form';
import CSS from "./ib-image-input.css";
import VariablesCSS from "./variables.css";
import {
JBImageInputBridge,
JBImageInputConfig,
JBImagesImageInputElements,
ValidationValue,
ViewStatus,
} from "./types";
import { registerDefaultVariables } from 'jb-core/theme';
import { renderHTML } from "./render";
import { dictionary } from "./i18n";
import { i18n } from "jb-core/i18n";
export * from './types.js';
export class JBImageInputWebComponent<TValue = File> extends HTMLElement implements WithValidation<ValidationValue<TValue>>, JBFormInputStandards<TValue> {
static get formAssociated() {
return true;
}
//TODO: this component need refactor for ui design to show better loading in download & upload and better effect for succeed upload and Download
get value() {
return this.#value;
}
set value(value) {
this.#setValue(value);
}
#isAutoValidationDisabled = false;
get isAutoValidationDisabled(): boolean {
return this.#isAutoValidationDisabled;
}
set isAutoValidationDisabled(value: boolean) {
this.#isAutoValidationDisabled = value;
}
#status: string | null = null;
#virtualInputFile!: HTMLInputElement;
#elements!: JBImagesImageInputElements;
get selectedImageType() {
return this.#file.type;
}
get status() {
//it is read only variable
return this.#status;
}
get multiple() {
return this.#multiple;
}
set multiple(value) {
this.#multiple = value;
if (this.#multiple) {
this.#virtualInputFile.setAttribute("multiple", "multiple");
} else {
this.#virtualInputFile.removeAttribute("multiple");
}
}
#acceptType = "image/jpeg,image/jpg,image/png,image/svg+xml";
get acceptTypes() {
return this.#acceptType;
}
set acceptTypes(value) {
this.#acceptType = value;
if (this.#virtualInputFile) {
this.#virtualInputFile.accept = value;
}
}
#maxFileSize: number | null = null;
#value: TValue | null = null;
#file: File | null = null;
#uploadProgressPercent: number | null = null;
get file() {
return this.#file;
}
imageBase64Value: string | null = null;
get maxFileSize() {
return this.#maxFileSize;
}
/**
* @description max file size in bytes
*/
set maxFileSize(value) {
if (value == null) {
this.#maxFileSize = null;
} else {
if (!isNaN(value) && typeof value == "number") {
this.#maxFileSize = value;
}
}
}
#disabled = false;
get disabled() {
return this.#disabled;
}
set disabled(value: boolean) {
this.#disabled = value;
if (value) {
//TODO: remove as any when typescript support
(this.#internals as any).states?.add("disabled");
} else {
(this.#internals as any).states?.delete("disabled");
}
}
#required = false;
set required(value: boolean) {
this.#required = value;
this.#validation.checkValidity({ showError: false });
}
get required() {
return this.#required;
}
#internals?: ElementInternals;
#validation = new ValidationHelper<ValidationValue<TValue>>({
showValidationError: this.showValidationError.bind(this),
clearValidationError: this.clearValidationError.bind(this),
getValue: () => ({ file: this.#file, value: this.#value }),
getValidations: this.#getInsideValidation.bind(this),
setValidationResult: this.#setValidationResult.bind(this),
getValueString: () => this.fileName
});
get validation() {
return this.#validation;
}
get name() {
return this.getAttribute('name') || '';
}
initialValue: TValue | null = null;
get isDirty(): boolean {
return this.#value !== this.initialValue;
}
constructor() {
super();
if (typeof this.attachInternals == "function") {
//some browser dont support attachInternals
this.#internals = this.attachInternals();
}
this.#initWebComponent();
this.#initProp();
this.#registerEventListener();
}
get fileName(): string {
return this.#file.name;
}
#initWebComponent() {
const shadowRoot = this.attachShadow({
mode: "open",
delegatesFocus: true,
clonable:true,
serializable:true,
});
registerDefaultVariables();
const html = `<style>${CSS} ${VariablesCSS}</style>\n${renderHTML()}`;
const element = document.createElement("template");
element.innerHTML = html;
shadowRoot.appendChild(element.content.cloneNode(true));
this.#elements = {
webComponent: shadowRoot.querySelector(".jb-image-input-web-component")!,
placeHolderWrapper: shadowRoot.querySelector(".placeholder-wrapper")!,
placeHolderTitle: shadowRoot.querySelector(".placeholder-title")!,
placeHolderMessageBox: shadowRoot.querySelector(".message-box")!,
image: shadowRoot.querySelector(".image-wrapper img")!,
overlay: {
container: shadowRoot.querySelector(".image-overlay")!,
deleteButton: shadowRoot.querySelector(".image-overlay .delete-button")!,
downloadButton: shadowRoot.querySelector(".image-overlay .download-button")!
},
errorOverlay: {
container: shadowRoot.querySelector(".error-overlay"),
message: shadowRoot.querySelector('.error-overlay .error-message')!
}
};
}
#multiple = false;
config: JBImageInputConfig = {
uploadUrl: "",
downloadUrl: "",
// developer can add every config he want to get on bridge functions
};
bridge: JBImageInputBridge<TValue> = {
uploader: function (file: File) {
return new Promise((resolve) => {
resolve(file as TValue);
});
},
downloader: function (value) {
return new Promise((resolve, reject) => {
if (typeof value == "string") {
fetch(value).then(res => res.blob()).then((value) => {
const reader = new window.FileReader();
reader.readAsDataURL(value);
reader.onload = function () {
const imageDataUrl = reader.result;
resolve(imageDataUrl as string);
};
}).catch(reject);
}
if (value instanceof File) {
JBImageInputWebComponent.ExtractBase64ImageFromFile(value).then((base64: string) => {
resolve(base64);
}
);
}
});
},
};
#initProp() {
this.acceptTypes = "image/jpeg,image/jpg,image/png,image/svg+xml";
this.#setStatus("empty");
this.#createVirtualInputFile();
}
#registerEventListener() {
this.#elements.placeHolderWrapper.addEventListener("click", this.openImageSelector.bind(this));
this.#elements.image.addEventListener("click", this.openImageSelector.bind(this));
this.#elements.overlay.container.addEventListener("click", this.openImageSelector.bind(this));
this.#elements.overlay.deleteButton.addEventListener("click", this.#onDeleteButtonClicked.bind(this));
this.#elements.overlay.downloadButton.addEventListener("click", this.#onDownloadButtonClicked.bind(this));
}
#createVirtualInputFile() {
this.#virtualInputFile = document.createElement("input");
this.#virtualInputFile.type = "file";
this.#virtualInputFile.accept = this.acceptTypes;
this.#virtualInputFile.addEventListener("change", (e) =>
this.#onImageSelected(e)
);
}
/**
* @public
* @description will open image selector
*/
openImageSelector() {
this.#virtualInputFile.click();
}
static get observedAttributes() {
return ["required", "label", "multiple", "message"];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
// do something when an attribute has changed
this.#onAttributeChange(name, newValue);
}
#onAttributeChange(name: string, value: string) {
switch (name) {
case "required":
this.required = (value || value === '') && value !== 'false';
break;
case "label":
this.#internals.ariaLabel = value;
if (this.#elements.placeHolderTitle) {
this.#elements.placeHolderTitle.innerHTML = value;
}
break;
case "multiple":
this.multiple = value === "true";
break;
case "message":
this.#elements.placeHolderMessageBox.innerHTML = value;
this.#internals.ariaDescription = value;
break;
}
}
#dispatchOnImagesSelected(files: FileList) {
const event = new CustomEvent("imageSelected", {
detail: {
files: files,
},
});
this.dispatchEvent(event);
}
#onImageSelected(e: Event) {
const files = (e.target as HTMLInputElement).files;
if (files && files?.length > 0) {
//if user select file and not click on cancel
//when user select a image from his computer but dont want to edit
this.#dispatchOnImagesSelected(files);
const file = files[0];
//reset virtual input value so it can reselect image
this.#virtualInputFile.value = null;
this.selectImageByFile(file);
}
}
/**
* inject file to image uploader like when user select it
* @public
* @param {File} file
*/
async selectImageByFile(file: File) {
const validationRes = await this.validation.checkValidity({ showError: true, value: { file, value: null } });
const maxSizeExceed = this.maxFileSize ? file.size > this.maxFileSize : false;
if (maxSizeExceed) {
this.#dispatchMaxSizeExceedEvent(file);
}
if (validationRes.isAllValid) {
this.#setImageToSelectedFile(file);
this.#uploadImage(file);
}
}
#setImageToSelectedFile(file: File) {
//this function called when user select file and upload type is manual so we show image from local
this.#file = file;
this.#dispatchOnChangeEvent();
}
#dispatchMaxSizeExceedEvent(file: File) {
const event = new CustomEvent("maxSizeExceed", { detail: { file }, cancelable: false });
this.dispatchEvent(event);
}
static ExtractBase64ImageFromFile(file: File) {
return new Promise((resolved, rejected) => {
const reader = new FileReader();
reader.onload = (e) => {
const mainImageSource = e.target?.result;
if (mainImageSource) {
resolved(mainImageSource);
} else {
rejected(e);
}
};
reader.readAsDataURL(file);
});
}
#uploadImage(file: File) {
this.#setStatus("uploading");
const promise = this.bridge.uploader(
file,
this.config,
this.onProgressImageUpload.bind(this)
);
promise
.then((data: TValue) => this.#onSuccessImageUpload(data))
.catch(() => this.#onErrorImageUpload());
}
#onSuccessImageUpload(data: TValue) {
const prevValue = this.value;
this.#setStatus("uploaded");
this.value = data;
const dispatchedEvent = this.#dispatchOnChangeEvent();
if (dispatchedEvent.defaultPrevented) {
//this will set status as well as value
this.value = prevValue;
}
}
#dispatchOnChangeEvent() {
const event = new Event("change", { bubbles: true, composed: true, cancelable: false });
this.dispatchEvent(event);
return event;
}
#onErrorImageUpload() {
// //we reset our virtual input becuase selected image does not upload well
if (this.value) {
this.#setStatus("downloaded");
} else {
this.#setStatus("empty");
}
this.#virtualInputFile.value = "";
}
onProgressImageUpload(percent: number) {
//TODO: add animation for upload
this.#uploadProgressPercent = percent;
}
#onSuccessImageDownload(base64Image: string) {
this.#setStatus("downloaded");
this.imageBase64Value = base64Image;
this.#elements.image.setAttribute("src", base64Image);
}
#setStatus(status: ViewStatus) {
this.#elements.webComponent.setAttribute("status", status);
this.#status = status;
}
showValidationError(error: ShowValidationErrorParameters | string) {
const message = typeof error == "string" ? error : error.message;
this.#elements.webComponent.classList.add("--has-error");
if (this.#value) {
this.#showOverlayError(message);
} else {
this.#elements.placeHolderMessageBox.innerHTML = message;
this.#elements.placeHolderMessageBox.classList.add("error");
}
}
clearValidationError() {
this.#elements.webComponent.classList.remove("--has-error");
this.#elements.placeHolderMessageBox.innerHTML = this.getAttribute("message") || "";
this.#elements.placeHolderMessageBox.classList.remove("error");
}
#showOverlayError(message: string) {
this.#elements.errorOverlay.message.innerHTML = message;
this.#elements.errorOverlay.container.style.display = "flex";
setTimeout(() => {
this.#elements.errorOverlay.message.innerHTML = "";
this.#elements.errorOverlay.container.style.display = "none";
}, 2000);
}
#getInsideValidation() {
const ValidationList: ValidationItem<ValidationValue<TValue>>[] = [];
if (this.required) {
const message = this.getAttribute("required").length > 0 ? this.getAttribute("required") : dictionary.get(i18n, "requiredMessage");
ValidationList.push({
validator: ({ file, value }) => {
return file !== null || value != null;
},
message: message,
stateType: "valueMissing",
});
}
if (this.#maxFileSize) {
ValidationList.push({
validator: ({ file }) => {
if (file == null) {
return true;
}
if (file.size >= this.#maxFileSize) {
const sizeFormatter = new Intl.NumberFormat(i18n.locale, {
style: 'unit',
unit: 'byte',
notation: "compact",
unitDisplay: "narrow",
})
return dictionary.get(i18n, "maxSizeExceed")(sizeFormatter.format(this.#maxFileSize), sizeFormatter.format(file.size))
}
return true;
},
message: "",
stateType: "rangeOverflow",
});
}
return ValidationList;
}
/**
* @public
* @description this method used to check for validity but doesn't show error to user and just return the result
* this method used by #internal of component
*/
checkValidity(): boolean {
const validationResult = this.#validation.checkValiditySync({ showError: false });
if (!validationResult.isAllValid) {
const event = new CustomEvent('invalid');
this.dispatchEvent(event);
}
return validationResult.isAllValid;
}
/**
* @public
* @description this method used to check for validity and show error to user
*/
reportValidity(): boolean {
const validationResult = this.#validation.checkValiditySync({ showError: true });
if (!validationResult.isAllValid) {
const event = new CustomEvent('invalid');
this.dispatchEvent(event);
}
return validationResult.isAllValid;
}
/**
* @description this method called on every checkValidity calls and update validation result of #internal
*/
#setValidationResult(result: ValidationResult<ValidationValue<TValue>>) {
if (result.isAllValid) {
this.#internals.setValidity({}, '');
} else {
const states: ValidityStateFlags = {};
let message = "";
result.validationList.forEach((res) => {
if (!res.isValid) {
if (res.validation.stateType) { states[res.validation.stateType] = true; }
if (message == '') { message = res.message; }
}
});
this.#internals.setValidity(states, message);
}
}
get validationMessage() {
return this.#internals.validationMessage;
}
#onDeleteButtonClicked(e: MouseEvent) {
e.stopPropagation();
this.#setValue(null);
this.validation.checkValiditySync({showError: true});
this.#dispatchOnChangeEvent();
}
#onDownloadButtonClicked(e: MouseEvent) {
e.stopPropagation();
const base64String = this.#elements.image.getAttribute('src');
const imageType = base64String.match(/[^:/]\w+(?=;|,)/)[0];
const a = document.createElement("a");
a.href = base64String;
a.download = "Image." + imageType;
a.click();
}
#setValue(value: TValue) {
this.#value = value;
if (value != null) {
if (value instanceof File) {
this.#file = value;
JBImageInputWebComponent.ExtractBase64ImageFromFile(value).then(
this.#onSuccessImageDownload.bind(this)
);
} else {
this.bridge
.downloader(value, this.config)
.then(this.#onSuccessImageDownload.bind(this));
}
} else {
this.#file = null;
this.#setStatus("empty");
}
}
}
const myElementNotExists = !customElements.get("jb-image-input");
if (myElementNotExists) {
window.customElements.define("jb-image-input", JBImageInputWebComponent);
}