diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index 42b51de5fb7..f067ee94780 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -71,6 +71,17 @@ export const CustomImageBlock: React.FC = (props) => { const containerRect = useRef(null); const imageRef = useRef(null); + const updateAttributesSafely = useCallback( + (attributes: Partial, errorMessage: string) => { + try { + updateAttributes(attributes); + } catch (error) { + console.error(`${errorMessage}:`, error); + } + }, + [updateAttributes] + ); + const handleImageLoad = useCallback(() => { const img = imageRef.current; if (!img) return; @@ -105,17 +116,25 @@ export const CustomImageBlock: React.FC = (props) => { }; setSize(initialComputedSize); - updateAttributes(initialComputedSize); + updateAttributesSafely( + initialComputedSize, + "Failed to update attributes while initializing an image for the first time:" + ); } else { // as the aspect ratio in not stored for old images, we need to update the attrs - setSize((prevSize) => { - const newSize = { ...prevSize, aspectRatio }; - updateAttributes(newSize); - return newSize; - }); + if (!aspectRatio) { + setSize((prevSize) => { + const newSize = { ...prevSize, aspectRatio }; + updateAttributesSafely( + newSize, + "Failed to update attributes while initializing images with width but no aspect ratio:" + ); + return newSize; + }); + } } setInitialResizeComplete(true); - }, [width, updateAttributes, editorContainer]); + }, [width, updateAttributes, editorContainer, aspectRatio]); // for real time resizing useLayoutEffect(() => { @@ -142,7 +161,7 @@ export const CustomImageBlock: React.FC = (props) => { const handleResizeEnd = useCallback(() => { setIsResizing(false); - updateAttributes(size); + updateAttributesSafely(size, "Failed to update attributes at the end of resizing:"); }, [size, updateAttributes]); const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index 89cf36ca52b..b5c52db66c3 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -5,9 +5,7 @@ import { ImageIcon } from "lucide-react"; // helpers import { cn } from "@/helpers/common"; // hooks -import { useUploader, useDropZone } from "@/hooks/use-file-upload"; -// plugins -import { isFileValid } from "@/plugins/image"; +import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; // extensions import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; @@ -74,7 +72,11 @@ export const CustomImageUploader = (props: { ); // hooks const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem }); - const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + uploader: uploadFile, + editor, + pos: getPos(), + }); // the meta data of the image component const meta = useMemo( @@ -82,9 +84,6 @@ export const CustomImageUploader = (props: { [imageComponentImageFileMap, imageEntityId] ); - // if the image component is dropped, we check if it has an existing file - const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]); - // after the image component is mounted we start the upload process based on // it's uploaded useEffect(() => { @@ -100,27 +99,20 @@ export const CustomImageUploader = (props: { } }, [meta, uploadFile, imageComponentImageFileMap]); - // check if the image is dropped and set the local image as the existing file - useEffect(() => { - if (existingFile) { - uploadFile(existingFile); - } - }, [existingFile, uploadFile]); - const onFileChange = useCallback( - (e: ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - if (isFileValid(file)) { - uploadFile(file); - } + async (e: ChangeEvent) => { + e.preventDefault(); + const fileList = e.target.files; + if (!fileList) { + return; } + await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile); }, - [uploadFile] + [uploadFile, editor, getPos] ); const getDisplayMessage = useCallback(() => { - const isUploading = isImageBeingUploaded || existingFile; + const isUploading = isImageBeingUploaded; if (failedToLoadImage) { return "Error loading image"; } @@ -134,13 +126,14 @@ export const CustomImageUploader = (props: { } return "Add an image"; - }, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]); + }, [draggedInside, failedToLoadImage, isImageBeingUploaded]); return (
{ - if (!failedToLoadImage) { + if (!failedToLoadImage && editor.isEditable) { fileInputRef.current?.click(); } }} @@ -167,6 +160,7 @@ export const CustomImageUploader = (props: { type="file" accept=".jpg,.jpeg,.png,.webp" onChange={onFileChange} + multiple />
); diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx index 8d66a5f9f58..2044f03bf5d 100644 --- a/packages/editor/src/core/extensions/drop.tsx +++ b/packages/editor/src/core/extensions/drop.tsx @@ -21,7 +21,7 @@ export const DropHandlerExtension = () => if (imageFiles.length > 0) { const pos = view.state.selection.from; - insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" }); + insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); } return true; } @@ -41,7 +41,7 @@ export const DropHandlerExtension = () => if (coordinates) { const pos = coordinates.pos; - insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" }); + insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); } return true; } @@ -54,7 +54,7 @@ export const DropHandlerExtension = () => }, }); -const insertImages = async ({ +export const insertImagesSafely = async ({ editor, files, initialPos, @@ -72,13 +72,6 @@ const insertImages = async ({ const docSize = editor.state.doc.content.size; pos = Math.min(pos, docSize); - // Check if the position has a non-empty node - const nodeAtPos = editor.state.doc.nodeAt(pos); - if (nodeAtPos && nodeAtPos.content.size > 0) { - // Move to the end of the current node - pos += nodeAtPos.nodeSize; - } - try { // Insert the image at the current position editor.commands.insertImageComponent({ file, pos, event }); diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index fee33061235..65e36c01ae6 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -126,7 +126,7 @@ export const useEditor = (props: CustomEditorProps) => { forwardedRef, () => ({ clearEditor: (emitUpdate = false) => { - editorRef.current?.commands.clearContent(emitUpdate); + editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index 5dfa025e594..f1bc8c8a136 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,6 +1,7 @@ import { DragEvent, useCallback, useEffect, useState } from "react"; import { Editor } from "@tiptap/core"; import { isFileValid } from "@/plugins/image"; +import { insertImagesSafely } from "@/extensions/drop"; export const useUploader = ({ onUpload, @@ -63,7 +64,15 @@ export const useUploader = ({ return { uploading, uploadFile }; }; -export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => { +export const useDropZone = ({ + uploader, + editor, + pos, +}: { + uploader: (file: File) => Promise; + editor: Editor; + pos: number; +}) => { const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -86,40 +95,16 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => }, []); const onDrop = useCallback( - (e: DragEvent) => { + async (e: DragEvent) => { + e.preventDefault(); setDraggedInside(false); if (e.dataTransfer.files.length === 0) { return; } - const fileList = e.dataTransfer.files; - - const files: File[] = []; - - for (let i = 0; i < fileList.length; i += 1) { - const item = fileList.item(i); - if (item) { - files.push(item); - } - } - - if (files.some((file) => file.type.indexOf("image") === -1)) { - return; - } - - e.preventDefault(); - - const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1); - - const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined; - - if (file) { - uploader(file); - } else { - console.error("No file found"); - } + await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader); }, - [uploader] + [uploader, editor, pos] ); const onDragEnter = () => { @@ -143,3 +128,40 @@ function trimFileName(fileName: string, maxLength = 100) { return fileName; } + +// Upload the first image and insert the remaining images for uploading multiple image +// post insertion of image-component +export async function uploadFirstImageAndInsertRemaining( + editor: Editor, + fileList: FileList, + pos: number, + uploaderFn: (file: File) => Promise +) { + const filteredFiles: File[] = []; + for (let i = 0; i < fileList.length; i += 1) { + const item = fileList.item(i); + if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) { + filteredFiles.push(item); + } + } + if (filteredFiles.length !== fileList.length) { + console.warn("Some files were not images and have been ignored."); + } + if (filteredFiles.length === 0) { + console.error("No image files found to upload"); + return; + } + + // Upload the first image + const firstFile = filteredFiles[0]; + uploaderFn(firstFile); + + // Insert the remaining images + const remainingFiles = filteredFiles.slice(1); + + if (remainingFiles.length > 0) { + const docSize = editor.state.doc.content.size; + const posOfNextImageToBeInserted = Math.min(pos + 1, docSize); + insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" }); + } +} diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index add0508b996..6d1ed6fa9f1 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -70,8 +70,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { const editorRef: MutableRefObject = useRef(null); useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); + clearEditor: (emitUpdate = false) => { + editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 72bb913ae70..21c8cd24f61 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -17,6 +17,8 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag }); transactions.forEach((transaction) => { + // if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically) + if (transaction.getMeta("skipImageDeletion")) return; // transaction could be a selection if (!transaction.docChanged) return;