Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);

const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
Expand Down Expand Up @@ -145,8 +147,9 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
...prevSize,
width: ensurePixelString(nodeWidth),
height: ensurePixelString(nodeHeight),
aspectRatio: nodeAspectRatio,
}));
}, [nodeWidth, nodeHeight]);
}, [nodeWidth, nodeHeight, nodeAspectRatio]);

const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
Expand All @@ -159,7 +162,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {

setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
[size]
[size.aspectRatio]
);

const handleResizeEnd = useCallback(() => {
Expand All @@ -182,11 +185,15 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (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]);
Expand All @@ -203,7 +210,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (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)
Expand Down Expand Up @@ -231,9 +238,26 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (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", {
Expand Down Expand Up @@ -284,6 +308,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>) => 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<string | undefined>(undefined);
Expand All @@ -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 (
<NodeViewWrapper>
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>) => void;
}) => {
};

export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
failedToLoadImage,
Expand All @@ -36,8 +29,8 @@ export const CustomImageUploader = (props: {
// refs
const fileInputRef = useRef<HTMLInputElement>(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(
Expand Down
40 changes: 23 additions & 17 deletions packages/editor/src/core/extensions/custom-image/custom-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
restoreImage: (src: string) => () => Promise<void>;
getImageSource?: (path: string) => () => string;
};
}
Expand All @@ -40,8 +41,8 @@ export const CustomImageExtension = (props: TFileHandler) => {
const {
getAssetSrc,
upload,
delete: deleteImage,
restore: restoreImage,
delete: deleteImageFn,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;

Expand Down Expand Up @@ -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<string>();
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(),
Expand Down Expand Up @@ -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),
};
},
Expand Down
5 changes: 4 additions & 1 deletion packages/editor/src/core/extensions/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";

Expand Down
16 changes: 11 additions & 5 deletions packages/editor/src/core/extensions/image/extension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -24,23 +24,26 @@ 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),
];
},

onCreate(this) {
const imageSources = new Set<string>();
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);
}
Expand All @@ -65,6 +68,9 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const ImageExtensionWithoutProps = () =>
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
});
3 changes: 3 additions & 0 deletions packages/editor/src/core/extensions/image/read-only-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">)
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},

Expand Down
3 changes: 3 additions & 0 deletions packages/editor/src/core/plugins/image/restore-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down