From 5a0dc4a65ade815d7de836d9474148390140297f Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 21 Oct 2024 14:17:05 +0530 Subject: [PATCH] [PE-69] fix: image restoration fixed for new images in private bucket (#5839) * regression: image aspect ratio fix * fix: name of variables changed for clarity * fix: restore only on error * fix: restore image by handling it inside the image component * fix: image restoration fixed and aspect ratio added to old images to stop updates on load * fix: added back restoring logic for public images * fix: add conditions * fix: image attributes types * fix: return for old images * fix: remove passive false * fix: eslint fixes * fix: stopping infinite loading scenarios while restoring from error --- .../custom-image/components/image-block.tsx | 37 ++++++++++++--- .../custom-image/components/image-node.tsx | 17 +++---- .../components/image-uploader.tsx | 23 ++++------ .../extensions/custom-image/custom-image.ts | 46 +++++++++++-------- .../editor/src/core/extensions/extensions.tsx | 5 +- .../src/core/extensions/image/extension.tsx | 16 +++++-- .../image/image-extension-without-props.tsx | 3 ++ .../core/extensions/image/read-only-image.tsx | 3 ++ .../src/core/plugins/image/restore-image.ts | 3 ++ 9 files changed, 98 insertions(+), 55 deletions(-) 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 4793d0cda..b89633f4e 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 @@ -72,6 +72,8 @@ export const CustomImageBlock: React.FC = (props) => { const containerRef = useRef(null); const containerRect = useRef(null); const imageRef = useRef(null); + const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false); + const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); const updateAttributesSafely = useCallback( (attributes: Partial, errorMessage: string) => { @@ -145,8 +147,9 @@ export const CustomImageBlock: React.FC = (props) => { ...prevSize, width: ensurePixelString(nodeWidth), height: ensurePixelString(nodeHeight), + aspectRatio: nodeAspectRatio, })); - }, [nodeWidth, nodeHeight]); + }, [nodeWidth, nodeHeight, nodeAspectRatio]); const handleResize = useCallback( (e: MouseEvent | TouchEvent) => { @@ -159,7 +162,7 @@ export const CustomImageBlock: React.FC = (props) => { setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` })); }, - [size] + [size.aspectRatio] ); const handleResizeEnd = useCallback(() => { @@ -182,11 +185,15 @@ export const CustomImageBlock: React.FC = (props) => { window.addEventListener("mousemove", handleResize); window.addEventListener("mouseup", handleResizeEnd); window.addEventListener("mouseleave", handleResizeEnd); + window.addEventListener("touchmove", handleResize); + window.addEventListener("touchend", handleResizeEnd); return () => { window.removeEventListener("mousemove", handleResize); window.removeEventListener("mouseup", handleResizeEnd); window.removeEventListener("mouseleave", handleResizeEnd); + window.removeEventListener("touchmove", handleResize); + window.removeEventListener("touchend", handleResizeEnd); }; } }, [isResizing, handleResize, handleResizeEnd]); @@ -203,7 +210,7 @@ export const CustomImageBlock: React.FC = (props) => { // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete - const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete; + const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad; // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) const showImageUtils = remoteImageSrc && initialResizeComplete; // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) @@ -231,9 +238,26 @@ export const CustomImageBlock: React.FC = (props) => { ref={imageRef} src={displayedImageSrc} onLoad={handleImageLoad} - onError={(e) => { - console.error("Error loading image", e); - setFailedToLoadImage(true); + onError={async (e) => { + // for old image extension this command doesn't exist or if the image failed to load for the first time + if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) { + setFailedToLoadImage(true); + return; + } + + try { + setHasErroredOnFirstLoad(true); + // this is a type error from tiptap, don't remove await until it's fixed + await editor?.commands.restoreImage?.(remoteImageSrc); + imageRef.current.src = remoteImageSrc; + } catch { + // if the image failed to even restore, then show the error state + setFailedToLoadImage(true); + console.error("Error while loading image", e); + } finally { + setHasErroredOnFirstLoad(false); + setHasTriedRestoringImageOnce(true); + } }} width={size.width} className={cn("image-component block rounded-md", { @@ -284,6 +308,7 @@ export const CustomImageBlock: React.FC = (props) => { } )} onMouseDown={handleResizeStart} + onTouchStart={handleResizeStart} /> )} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index f743b0a3c..bdb8280c5 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -1,21 +1,23 @@ import { useEffect, useRef, useState } from "react"; -import { Node as ProsemirrorNode } from "@tiptap/pm/model"; -import { Editor, NodeViewWrapper } from "@tiptap/react"; +import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; // extensions import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; -export type CustomImageNodeViewProps = { +export type CustomImageComponentProps = { getPos: () => number; editor: Editor; - node: ProsemirrorNode & { + node: NodeViewProps["node"] & { attrs: ImageAttributes; }; - updateAttributes: (attrs: Record) => void; + updateAttributes: (attrs: ImageAttributes) => void; selected: boolean; }; +export type CustomImageNodeViewProps = NodeViewProps & CustomImageComponentProps; + export const CustomImageNode = (props: CustomImageNodeViewProps) => { const { getPos, editor, node, updateAttributes, selected } = props; + const { src: remoteImageSrc } = node.attrs; const [isUploaded, setIsUploaded] = useState(false); const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined); @@ -37,14 +39,13 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { // the image is already uploaded if the image-component node has src attribute // and we need to remove the blob from our file system useEffect(() => { - const remoteImageSrc = node.attrs.src; if (remoteImageSrc) { setIsUploaded(true); setImageFromFileSystem(undefined); } else { setIsUploaded(false); } - }, [node.attrs.src]); + }, [remoteImageSrc]); return ( @@ -55,7 +56,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { editorContainer={editorContainer} editor={editor} // @ts-expect-error function not expected here, but will still work - src={editor?.commands?.getImageSource?.(node.attrs.src)} + src={editor?.commands?.getImageSource?.(remoteImageSrc)} getPos={getPos} node={node} setEditorContainer={setEditorContainer} 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 67cb4e329..36f1361ee 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 @@ -1,27 +1,20 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; -import { Node as ProsemirrorNode } from "@tiptap/pm/model"; -import { Editor } from "@tiptap/core"; import { ImageIcon } from "lucide-react"; // helpers import { cn } from "@/helpers/common"; // hooks import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; // extensions -import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; +import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; -export const CustomImageUploader = (props: { - editor: Editor; - failedToLoadImage: boolean; - getPos: () => number; - loadImageFromFileSystem: (file: string) => void; +type CustomImageUploaderProps = CustomImageComponentProps & { maxFileSize: number; - node: ProsemirrorNode & { - attrs: ImageAttributes; - }; - selected: boolean; + loadImageFromFileSystem: (file: string) => void; + failedToLoadImage: boolean; setIsUploaded: (isUploaded: boolean) => void; - updateAttributes: (attrs: Record) => void; -}) => { +}; + +export const CustomImageUploader = (props: CustomImageUploaderProps) => { const { editor, failedToLoadImage, @@ -36,8 +29,8 @@ export const CustomImageUploader = (props: { // refs const fileInputRef = useRef(null); const hasTriggeredFilePickerRef = useRef(false); + const { id: imageEntityId } = node.attrs; // derived values - const imageEntityId = node.attrs.id; const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]); const onUpload = useCallback( diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 6dd5f0f19..2c5e2bb8d 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -22,6 +22,7 @@ declare module "@tiptap/core" { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (file: File) => () => Promise | undefined; + restoreImage: (src: string) => () => Promise; getImageSource?: (path: string) => () => string; }; } @@ -40,8 +41,8 @@ export const CustomImageExtension = (props: TFileHandler) => { const { getAssetSrc, upload, - delete: deleteImage, - restore: restoreImage, + delete: deleteImageFn, + restore: restoreImageFn, validation: { maxFileSize }, } = props; @@ -85,22 +86,6 @@ export const CustomImageExtension = (props: TFileHandler) => { return ["image-component", mergeAttributes(HTMLAttributes)]; }, - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - await restoreImage(src); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -110,11 +95,29 @@ export const CustomImageExtension = (props: TFileHandler) => { addProseMirrorPlugins() { return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), + TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), ]; }, + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name) { + if (!node.attrs.src?.startsWith("http")) return; + + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + await restoreImageFn(src); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + addStorage() { return { fileMap: new Map(), @@ -179,6 +182,9 @@ export const CustomImageExtension = (props: TFileHandler) => { const fileUrl = await upload(file); return fileUrl; }, + restoreImage: (src: string) => async () => { + await restoreImageFn(src); + }, getImageSource: (path: string) => () => getAssetSrc(path), }; }, diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index f4edfdb5c..f74a814c1 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -140,7 +140,10 @@ export const CoreEditorExtensions = (args: TArguments) => { if (editor.storage.imageComponent.uploadInProgress) return ""; const shouldHidePlaceholder = - editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + editor.isActive("table") || + editor.isActive("codeBlock") || + editor.isActive("image") || + editor.isActive("imageComponent"); if (shouldHidePlaceholder) return ""; diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index e430b88a8..f7666bfe2 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -11,9 +11,9 @@ import { CustomImageNode } from "@/extensions"; export const ImageExtension = (fileHandler: TFileHandler) => { const { - delete: deleteImage, getAssetSrc, - restore: restoreImage, + delete: deleteImageFn, + restore: restoreImageFn, validation: { maxFileSize }, } = fileHandler; @@ -24,10 +24,11 @@ export const ImageExtension = (fileHandler: TFileHandler) => { ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, + addProseMirrorPlugins() { return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), + TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), ]; }, @@ -35,12 +36,14 @@ export const ImageExtension = (fileHandler: TFileHandler) => { const imageSources = new Set(); this.editor.state.doc.descendants((node) => { if (node.type.name === this.name) { + if (!node.attrs.src?.startsWith("http")) return; + imageSources.add(node.attrs.src); } }); imageSources.forEach(async (src) => { try { - await restoreImage(src); + await restoreImageFn(src); } catch (error) { console.error("Error restoring image: ", error); } @@ -65,6 +68,9 @@ export const ImageExtension = (fileHandler: TFileHandler) => { height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index 0d505000c..bb6c5b4ad 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -11,6 +11,9 @@ export const ImageExtensionWithoutProps = () => height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, }); diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 7ba961cdb..c884a43ee 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -18,6 +18,9 @@ export const ReadOnlyImageExtension = (props: Pick) height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index 4d7279fff..4eecf01d7 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -25,6 +25,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor if (node.type.name !== nodeType) return; if (pos < 0 || pos > newState.doc.content.size) return; if (oldImageSources.has(node.attrs.src)) return; + // if the src is just a id (private bucket), then we don't need to handle restore from here but + // only while it fails to load + if (!node.attrs.src?.startsWith("http")) return; addedImages.push(node as ImageNode); });