W skrocie historia wyglada tak ze mam przycisk ktory pokazuje Modal. Robie to nastepujaco:
type ImageButtonProps = {
view: EditorView;
editor: Editor;
};
export function ImageButton({ view, editor }: ImageButtonProps) {
let [isModalVisible, setIsModalVisible] = useState(false);
return (
<>
<button
onClick={() => {
setIsModalVisible(true);
showModal(document.querySelector("#modal-base") as HTMLElement);
}}
>
Image
</button>
{}
{isModalVisible && (
<ImageUploadModal
view={view}
onClose={() => setIsModalVisible(false)}
uploadOptions={{ handler: defaultImageUploadHandler }}
editor={editor}
/>
)}
</>
);
}
Czyli do Modala przkeazuje funkcje onClose
ktora powinna go zamknac. Sam modal wyglada tak jak nizej gdzie zwracany jest przez Portal uploadContainer
ktory w szczegolnosci zawiera input uploadField
. I to wlasnie ten input jest problematyczny. Jego zadaniem jest otworzyc okno do wyboru pliku i w momencie w ktorym go klikam, ma on zuploadowac plik i wyswietlic jego preview. Robie to poprzez dodanie event listenera na uploadField
poprzez useEffect
nastepujaco
uploadFieldRef.current?.addEventListener("change", () => {
handleFileSelection(view);
});
Odpala sie on prawidlowo ale z jakiegos powodu dzieje sie to dwukrotnie :( Wiec dwa razy wykonywana jest metoda handleFileSelection
. Wydawalo mi sie ze problem moze byc wlasnie w tym FileReader
ze ale wyglada na to ze nie. To co robi funkcja handleFileSelection(view)
to wywoluje showImagePreviewAsync
i tutaj wlasnie ten FileReader
wchodzi w gre ktory wydaje mi sie teraz jest dobrze napisany:
let showImagePreviewAsync = (
file: File,
resolve: () => void,
reject: (error: string) => void
) => {
// const previewElement = imagePreviewRef.current!;
hideValidationError();
const validationResult = validateImage(file);
switch (validationResult) {
case ValidationResult.FileTooLarge:
showValidationError(_t("image_upload.upload_error_file_too_big"));
reject("file too large");
return;
case ValidationResult.InvalidFileType:
showValidationError(_t("image_upload.upload_error_unsupported_format"));
reject("invalid filetype");
return;
}
resetImagePreview();
console.log("Rendering");
let reader = new FileReader();
reader.onloadend = (readerEvent: ProgressEvent<FileReader>) => {
if (readerEvent?.target?.result) {
const previewElement = imagePreviewRef.current!;
const addImageButton = addImageButtonRef.current;
const _image = new Image();
_image.className = "hmx1 w-auto";
_image.title = file.name;
_image.src = reader.result as string;
_image.alt = _t("image_upload.uploaded_image_preview_alt");
previewElement.appendChild(_image);
previewElement.classList.remove("d-none");
image = file;
addImageButton!.disabled = false;
resolve();
}
};
reader.readAsDataURL(file);
};
Takze bylbym wdzieczny gdyby ktos zerknal dlaczego ten event sie wywoluje dwa razy. Ponizej pelny kod.
type ImageButtonProps = {
view: EditorView;
editor: Editor;
};
export function ImageButton({ view, editor }: ImageButtonProps) {
let [isModalVisible, setIsModalVisible] = useState(false);
return (
<>
<button
onClick={() => {
setIsModalVisible(true);
showModal(document.querySelector("#modal-base") as HTMLElement);
}}
>
Image
</button>
{}
{isModalVisible && (
<ImageUploadModal
view={view}
onClose={() => setIsModalVisible(false)}
uploadOptions={{ handler: defaultImageUploadHandler }}
editor={editor}
/>
)}
</>
);
}
/**
* Async image upload callback that is passed the uploaded file and returns a resolvable path to the image
* @param {File} file The uploaded image file or user entered image url
* @returns {string} The resolvable path to where the file was uploaded
*/
type ImageUploadHandlerCallback = (file: File) => Promise<string>;
/**
* Image upload options
*/
export interface ImageUploadOptions {
/**
* A function handling file uploads. Will receive the file to upload
* as the `file` parameter and needs to return a resolved promise with the URL of the uploaded file
*/
handler?: ImageUploadHandlerCallback;
/**
* The html to insert into the image uploader to designate the image storage provider
* NOTE: this is injected as-is and can potentially be a XSS hazard!
*/
brandingHtml?: string;
/**
* The html to insert into the image uploader to alert users of the uploaded image content policy
* NOTE: this is injected as-is and can potentially be a XSS hazard!
*/
contentPolicyHtml?: string;
/**
* If provided, will insert the html into a warning notice at the top of the image uploader
* NOTE: this is injected as-is and can potentially be a XSS hazard!
*/
warningNoticeHtml?: string;
/**
* If true, wraps all images in links that point to the uploaded image url
*/
wrapImagesInLinks?: boolean;
/**
* If true, all uploaded images will embedded as links to the image, rather than the image itself
* NOTE: this is only supported for images that are uploaded via the image uploader
*/
embedImagesAsLinks?: boolean;
/**
* If true, allow users to add images via an external url
*/
allowExternalUrls?: boolean;
}
/**
* Default image upload callback that posts to `/image/upload`,
* expecting a json response like `{ UploadedImage: "https://www.example.com/path/to/file" }`
* and returns `UploadedImage`'s value
* @param file The file to upload
*/
export async function defaultImageUploadHandler(file: File): Promise<string> {
const formData = new FormData();
// formData.append("file", file);
formData.append("key", "422a4e8bc4b4a8a67ee14c6ad3c0d69e");
formData.append("image", file);
const response = await fetch("https://api.imgbb.com/1/upload", {
method: "POST",
cache: "no-cache",
body: formData,
});
if (!response.ok) {
throw Error(
`Failed to upload image: ${response.status} - ${response.statusText}`
);
}
const json = await response.json(); //as { UploadedImage: string };
return json.data.url;
}
enum ValidationResult {
Ok,
FileTooLarge,
InvalidFileType,
}
let hide = () => {
hideModal(document.querySelector("#modal-base") as HTMLElement);
return true;
};
let show = () => {
showModal(document.querySelector("#modal-base") as HTMLElement);
return true;
};
type Props = {
view: EditorView;
uploadOptions: ImageUploadOptions;
// validateLink: (str: string) => boolean;
onClose: any;
editor: Editor;
};
export function ImageUploadModal({
view,
uploadOptions,
onClose,
editor,
}: Props) {
// let uploadOptions: ImageUploadOptions | null;
let image: File | null;
// let isVisible: boolean;
// TODO do external image urls need to support a different link validator?
// let validateLink: (str: string) => boolean;
// let addTransactionDispatcher: addTransactionDispatcher;
let uploadFieldRef = useRef<HTMLInputElement>(null);
let externalUrlRef = useRef<HTMLInputElement>(null);
let imagePreviewRef = useRef<HTMLDivElement>(null);
let ctaContainerRef = useRef<HTMLDivElement>(null);
let addImageButtonRef = useRef<HTMLButtonElement>(null);
let cancelButtonRef = useRef<HTMLButtonElement>(null);
let externalUrlInputContainerRef = useRef<HTMLDivElement>(null);
let warningRef = useRef<HTMLDivElement>(null);
let externalUrlTriggerRef = useRef<HTMLButtonElement>(null);
let externalUrlTriggerContainerRef = useRef<HTMLElement>(null);
let validationElementRef = useRef<HTMLElement>(null);
let ref = useRef<HTMLDivElement>(null);
let uploadField = (
<input
ref={uploadFieldRef}
type="file"
className="js-image-uploader-input v-visible-sr"
accept="image/*"
multiple={false}
id="fileUpload"
></input>
);
let uploadContainer = (
<div className="s-modal--dialog">
<div ref={ref} className="mt6 bc-black-400 js-image-uploader">
<h1>Image</h1>
<div
ref={warningRef}
className="s-notice s-notice__warning m12 mb0 js-warning-notice-html d-none"
role="status"
></div>
<div
ref={ctaContainerRef}
className="fs-body2 p12 pb0 js-cta-container"
>
<label
htmlFor={uploadFieldRef.current?.id}
className="d-inline-flex f:outline-ring s-link js-browse-button"
aria-controls="image-preview"
>
Browse
{uploadField}
</label>
, drag & drop
<span
ref={externalUrlTriggerContainerRef}
className="js-external-url-trigger-container d-none"
>
,{" "}
<button
ref={externalUrlTriggerRef}
type="button"
className="s-btn s-btn__link js-external-url-trigger"
>
enter a link
</button>
</span>
, or paste an image{" "}
<span className="fc-light fs-caption">Max size 2 MiB</span>
</div>
<div
ref={externalUrlInputContainerRef}
className="js-external-url-input-container p12 d-none"
>
<div className="d-flex fd-row ai-center sm:fd-column sm:ai-start">
<label
className="d-block s-label ws-nowrap mr4"
htmlFor="external-url-input"
>
External url
</label>
<input
ref={externalUrlRef}
id="external-url-input"
type="text"
className="s-input js-external-url-input"
placeholder="https://example.com/img.png"
/>
</div>
</div>
<div
ref={imagePreviewRef}
id="image-preview"
className="js-image-preview wmx100 pt12 px12 d-none"
></div>
<aside
ref={validationElementRef}
className="s-notice s-notice__warning d-none m8 js-validation-message"
role="status"
aria-hidden="true"
></aside>
<div className="d-flex jc-space-between ai-center p12 sm:fd-column sm:ai-start sm:g16">
<div>
<button
ref={addImageButtonRef}
className="s-btn s-btn__primary ws-nowrap mr8 js-add-image"
type="button"
disabled
>
Add image
</button>
<button
ref={cancelButtonRef}
className="s-btn ws-nowrap js-cancel-button"
type="button"
onClick={onClose}
>
Cancel
</button>
</div>
<div className="d-flex fd-column fs-caption fc-black-300 s-anchors s-anchors__muted">
<div className="js-branding-html">
{uploadOptions?.brandingHtml}
</div>
<div className="js-content-policy-html">
{uploadOptions?.contentPolicyHtml}
</div>
</div>
</div>
</div>
</div>
);
useEffect(() => {
uploadFieldRef.current?.addEventListener("change", () => {
handleFileSelection(view);
});
ref.current?.addEventListener("dragenter", highlightDropArea);
ref.current?.addEventListener("dragover", highlightDropArea);
// we need this handler on top of the plugin's handleDrop() to make
// sure we're handling drop events on the upload container itself properly
ref.current?.addEventListener("drop", (event: DragEvent) => {
unhighlightDropArea(event);
handleDrop(event, view);
});
// we need this handler on top of the plugin's handlePaste() to make
// sure we're handling paste events on the upload container itself properly
ref.current?.addEventListener("paste", (event: ClipboardEvent) => {
handlePaste(event, view);
});
ref.current?.addEventListener("dragleave", (event: DragEvent) =>
unhighlightDropArea(event)
);
cancelButtonRef.current?.addEventListener("click", () => {
hide();
});
addImageButtonRef.current?.addEventListener("click", (e: Event) => {
void handleUploadTrigger(e, image!, view);
onClose();
});
if (uploadOptions?.warningNoticeHtml) {
const warning = warningRef.current!;
warning.classList.remove("d-none");
// XSS "safe": this html is passed in via the editor options; it is not our job to sanitize it
// eslint-disable-next-line no-unsanitized/property
warning.innerHTML = uploadOptions?.warningNoticeHtml;
}
if (uploadOptions.allowExternalUrls) {
externalUrlTriggerContainerRef.current?.classList.remove("d-none");
externalUrlTriggerContainerRef.current?.addEventListener("click", () => {
toggleExternalUrlInput(true);
});
externalUrlRef.current?.addEventListener("input", (e) => {
validateExternalUrl((e.target as HTMLInputElement).value);
});
}
return () => {
uploadFieldRef.current?.removeEventListener("change", () => {
handleFileSelection(view);
});
};
});
async function handleUploadTrigger(
event: Event,
file: File,
view: EditorView
): Promise<void> {
const externalUrl = externalUrlRef.current?.value;
const urlIsValue = externalUrl && validateLink(externalUrl);
if (!file && !urlIsValue) {
return;
}
let resume: (resume: boolean) => void;
const resumePromise = new Promise((resolve) => {
resume = (r) => resolve(r);
});
// const canceled = !dispatchEditorEvent(view.dom, "image-upload", {
// file: file || externalUrl,
// resume,
// });
let canceled = false;
if (canceled) {
const id = {};
addImagePlaceholder(view, id);
const resume = await resumePromise;
removeImagePlaceholder(view, id);
if (resume) {
void startImageUpload(view, file || externalUrl);
}
} else {
void startImageUpload(view, file || externalUrl);
}
resetUploader();
hide();
}
let handlePaste = (event: ClipboardEvent, view: EditorView): void => {
resetImagePreview();
const files = event.clipboardData?.files;
if (view.state.selection.$from.parent.inlineContent && files?.length) {
void showImagePreview(files[0]);
}
};
let highlightDropArea = (event: DragEvent): void => {
ref.current?.classList.add("bs-ring");
ref.current?.classList.add("bc-blue-300");
event.preventDefault();
event.stopPropagation();
};
let unhighlightDropArea = (event: DragEvent): void => {
ref.current?.classList.remove("bs-ring");
ref.current?.classList.remove("bc-blue-300");
event.preventDefault();
event.stopPropagation();
};
let resetImagePreview = (): void => {
imagePreviewRef.current!.innerHTML = "";
image = null;
addImageButtonRef.current!.disabled = true;
};
let handleDrop = (event: DragEvent, view: EditorView): void => {
resetImagePreview();
const files = event.dataTransfer?.files;
if (view.state.selection.$from.parent.inlineContent && files?.length) {
void showImagePreview(files[0]);
}
};
let showImagePreview = (file: File): Promise<void> => {
const promise = new Promise<void>((resolve, reject) =>
showImagePreviewAsync(file, resolve, reject)
);
return promise;
};
let showImagePreviewAsync = (
file: File,
resolve: () => void,
reject: (error: string) => void
) => {
// const previewElement = imagePreviewRef.current!;
hideValidationError();
const validationResult = validateImage(file);
switch (validationResult) {
case ValidationResult.FileTooLarge:
showValidationError(_t("image_upload.upload_error_file_too_big"));
reject("file too large");
return;
case ValidationResult.InvalidFileType:
showValidationError(_t("image_upload.upload_error_unsupported_format"));
reject("invalid filetype");
return;
}
resetImagePreview();
console.log("Rendering");
let reader = new FileReader();
reader.onloadend = (readerEvent: ProgressEvent<FileReader>) => {
if (readerEvent?.target?.result) {
const previewElement = imagePreviewRef.current!;
const addImageButton = addImageButtonRef.current;
const _image = new Image();
_image.className = "hmx1 w-auto";
_image.title = file.name;
_image.src = reader.result as string;
_image.alt = _t("image_upload.uploaded_image_preview_alt");
previewElement.appendChild(_image);
previewElement.classList.remove("d-none");
image = file;
addImageButton!.disabled = false;
resolve();
}
};
reader.readAsDataURL(file);
};
let hideValidationError = (): void => {
const validationElement = validationElementRef.current!;
validationElement.classList.add("d-none");
validationElement.classList.remove("s-notice__warning");
validationElement.classList.remove("s-notice__danger");
validationElement.innerHTML = "";
};
let showValidationError = (errorMessage: string, level = "warning"): void => {
uploadFieldRef.current!.value = "";
const validationElement = validationElementRef.current!;
if (level === "warning") {
validationElement.classList.remove("s-notice__danger");
validationElement.classList.add("s-notice__warning");
} else {
validationElement.classList.remove("s-notice__warning");
validationElement.classList.add("s-notice__danger");
}
validationElement.classList.remove("d-none");
validationElement.textContent = errorMessage;
};
let validateImage = (image: File): ValidationResult => {
const validTypes = ["image/jpeg", "image/png", "image/gif"];
const sizeLimit = 0x200000; // 2 MiB
if (validTypes.indexOf(image.type) === -1) {
return ValidationResult.InvalidFileType;
}
if (image.size >= sizeLimit) {
return ValidationResult.FileTooLarge;
}
return ValidationResult.Ok;
};
function handleFileSelection(view: EditorView): void {
resetImagePreview();
const files = uploadFieldRef.current?.files!;
if (view.state.selection.$from.parent.inlineContent && files.length) {
void showImagePreview(files[0]);
}
}
function toggleExternalUrlInput(show: boolean): void {
const cta = ctaContainerRef.current!;
const container = externalUrlInputContainerRef.current!;
cta.classList.toggle("d-none", show);
container.classList.toggle("d-none", !show);
externalUrlRef.current!.value = "";
}
function validateExternalUrl(url: string): void {
resetImagePreview();
const addImageButton = addImageButtonRef.current!;
if (!validateLink(url)) {
showValidationError(
_t("image_upload.external_url_validation_error"),
"danger"
);
addImageButton.disabled = true;
} else {
hideValidationError();
addImageButton.disabled = false;
}
}
function resetUploader(): void {
resetImagePreview();
toggleExternalUrlInput(false);
hideValidationError();
uploadFieldRef.current!.value = "";
}
function addImagePlaceholder(view: EditorView, id: unknown): void {
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
// this.key.setMeta(tr, {
// add: { id, pos: tr.selection.from },
// // explicitly clear out any pasted/dropped file on upload
// file: null,
// shouldShow: false,
// });
view.dispatch(tr);
}
function removeImagePlaceholder(
view: EditorView,
id: unknown,
transaction?: Transaction
): void {
let tr = transaction || view.state.tr;
// tr = this.key.setMeta(tr, {
// remove: { id },
// file: null,
// shouldShow: false,
// });
view.dispatch(tr);
}
function startImageUpload(
view: EditorView,
file: File | string
): Promise<void> | undefined {
// A fresh object to act as the ID for this upload
const id = {};
// addImagePlaceholder(view, id);
if (!uploadOptions?.handler) {
// purposefully log an error to the dev console
// don't use our internal `log` implementation, it only logs on dev builds
// eslint-disable-next-line no-console
console.error(
"No upload handler registered. Ensure you set a proper handler on the editor's options.imageUploadHandler"
);
return;
}
return uploadOptions.handler(file as File).then(
(url) => {
// // ON SUCCESS
// // find where we inserted our placeholder so the content insert knows where to go
// const decos = this.key.getState(view.state).decorations;
// const found = decos.find(null, null, (spec: NodeSpec) => spec.id == id);
// const pos = found.length ? found[0].from : null;
// // If the content around the placeholder has been deleted, drop the image
// if (pos === null) return;
// // get the transaction from the dispatcher
// const tr = this.addTransactionDispatcher(view.state, url, pos);
// removeImagePlaceholder(view, id, tr);
view.focus();
let image = {
"media-type": "img" as "img",
src: url,
alt: "",
title: "",
};
editor.commands.setMedia(image);
},
() => {
// ON ERROR
// reshow the image uploader along with an error message
show();
removeImagePlaceholder(view, id, view.state.tr);
showValidationError(_t("image_upload.upload_error_generic"), "error");
}
);
}
return ReactDOM.createPortal(
uploadContainer,
document.getElementById("link-editor")!
);
// TODO:
// 1. escape HTML for brandingHTML, contentPolicyHtml
}
/**
* Hides the image uploader
* @param view The current editor view
*/
export function hideImageUploader(view: EditorView): void {
hide();
}
/** Shows the image uploader
* @param view The current editor view
* @param file The file to upload
*/
export function showImageUploader(view: EditorView, file?: File): void {
show();
}
/**
* Checks if the image-upload functionality is enabled
* @param state The current editor state
*/
export function imageUploaderEnabled(state: EditorState): boolean {
return true;
}