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 4793d0cda9d..b89633f4e4d 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 f743b0a3c1d..bdb8280c5b4 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 67cb4e32927..36f1361ee80 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 6dd5f0f19e6..2c5e2bb8d43 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,36 +86,38 @@ export const CustomImageExtension = (props: TFileHandler) => { return ["image-component", mergeAttributes(HTMLAttributes)]; }, + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addProseMirrorPlugins() { + return [ + 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 restoreImage(src); + await restoreImageFn(src); } catch (error) { console.error("Error restoring image: ", error); } }); }, - addKeyboardShortcuts() { - return { - ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), - ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), - }; - }, - - addProseMirrorPlugins() { - return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), - ]; - }, - 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 f4edfdb5c9b..f74a814c11f 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 e430b88a8d7..f7666bfe24b 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 0d505000c7e..bb6c5b4ad81 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 7ba961cdb67..c884a43ee72 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 4d7279fffcb..4eecf01d7e2 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); });