Compare commits

...

6 Commits

Author SHA1 Message Date
Palanikannan M
8303acb723 fix: loading of images 2024-09-23 19:01:15 +05:30
Palanikannan M
c18c1a47dd fix: handling file being uploaded at 2 places 2024-09-23 18:58:26 +05:30
Palanikannan M
f8aca6a574 fix: image block uploading with preview images 2024-09-23 18:46:14 +05:30
Palanikannan M
295fba6a0d fix: minor fixes 2024-09-19 14:22:38 +05:30
Palanikannan M
d94046e69a fix: added preview image for upload 2024-09-18 20:55:48 +05:30
Aaryan Khandelwal
432be1a506 fix: image resize event listeners 2024-09-17 18:38:15 +05:30
9 changed files with 654 additions and 213 deletions

View File

@@ -0,0 +1,338 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Editor } from "@tiptap/core";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// Hooks
import { useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// Plugins
import { isFileValid } from "@/plugins/image";
import { NodeSelection } from "@tiptap/pm/state";
import { UploadEntity } from "../custom-image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
type Pixel = `${number}px`;
export type ImageAttributes = {
src: string | null;
width: Pixel | "35%";
height: Pixel | "auto";
aspectRatio: number | null;
id: string | null;
};
export type CustomImageBlockProps = {
editor: Editor;
getPos: () => number;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
fileInputRef: RefType;
droppedFileBlob?: string;
uploadFile: (file: File) => Promise<void>;
isFileUploading: boolean;
initialEditorContainerWidth: number;
uploadEntity?: UploadEntity;
};
const MIN_SIZE = 100;
type Size = Omit<ImageAttributes, "src" | "id">;
export const CustomImageBlockNew = (props: CustomImageBlockProps) => {
const {
editor,
getPos,
node,
isFileUploading,
selected,
fileInputRef,
droppedFileBlob,
uploadFile,
initialEditorContainerWidth,
updateAttributes,
uploadEntity,
} = props;
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
uploader: uploadFile,
});
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
// refs
const imageRef = useRef<HTMLImageElement>(null);
const localRef = useRef<HTMLInputElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
// states
const [displayedSrc, setDisplayedSrc] = useState<string | null>(null);
const [size, setSize] = useState<Size>({
width: nodeWidth || "35%",
height: nodeHeight || "auto",
aspectRatio: null,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
// for realtime updates of the width and height and initializing size
useEffect(() => {
setSize({ width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio });
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
// for loading uploaded files via file picker
const onFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && isFileValid(file)) {
const reader = new FileReader();
reader.onload = () => {
setDisplayedSrc(reader.result as string);
};
reader.readAsDataURL(file);
uploadFile(file);
}
},
[uploadFile, editor.storage.image]
);
// for loading dropped files
useEffect(() => {
if (droppedFileBlob) {
setDisplayedSrc(droppedFileBlob);
}
}, [droppedFileBlob]);
// for loading remote images
useEffect(() => {
if (node.attrs.src) {
setDisplayedSrc(node.attrs.src);
}
}, [node.attrs.src]);
// on first load, set the aspect ratio and height of the image based on
// conditions
const handleImageLoad = useCallback(() => {
setInitialResizeComplete(false);
const img = imageRef.current;
if (!img) {
console.error("Image reference is undefined");
return;
}
let aspectRatio = nodeAspectRatio;
if (!aspectRatio) {
aspectRatio = img.naturalWidth / img.naturalHeight;
}
if (nodeWidth === "35%") {
// the initial width hasn't been set and has to be set to 35% of the editor container
const closestEditorContainer = img.closest(".editor-container");
let editorWidth = initialEditorContainerWidth;
if (closestEditorContainer) {
editorWidth = closestEditorContainer.clientWidth;
}
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const newSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatio,
};
setSize(newSize);
updateAttributes(newSize);
} else {
// if the width has been set, we need to update the aspect ratio and height
const newHeight = Number(nodeWidth?.replace("px", "")) / aspectRatio;
setSize((prevSize) => ({ ...prevSize, aspectRatio, height: `${newHeight}px` }));
if (newHeight !== Number(nodeHeight?.replace("px", ""))) {
updateAttributes({ height: `${newHeight}px` });
}
if (nodeAspectRatio !== aspectRatio) {
updateAttributes({ aspectRatio });
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributes, nodeAspectRatio, nodeHeight]);
// resizing lifecycle
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
[size]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributes({ width: size.width, height: size.height });
}, [size, updateAttributes]);
// handle resizing
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
const handleImageMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos]
);
const isRemoteImageBeingUploaded = node.attrs.width === "35%" && node.attrs.height === "auto" && !node.attrs.src;
console.log(
"uploadEntity",
!displayedSrc,
!isRemoteImageBeingUploaded,
uploadEntity?.event == "insert",
!displayedSrc || (!isRemoteImageBeingUploaded && uploadEntity?.event === "insert")
);
return (
<>
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
aspectRatio: size.aspectRatio ?? undefined,
}}
>
{/* if the image hasn't completed it's initial resize but has a src, show a loading placeholder */}
{!initialResizeComplete && displayedSrc && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{
width: size.width === "35%" ? `${0.35 * initialEditorContainerWidth}px` : size.width,
height: size.height === "auto" ? "100px" : size.height,
}}
/>
)}
{/* if the image has a src, load the image and hide it until the initial resize is complete (while showing the above loader) */}
{displayedSrc && (
<img
ref={imageRef}
src={displayedSrc || ""}
onLoad={handleImageLoad}
className={cn("w-full h-auto rounded-md", {
hidden: !initialResizeComplete,
"blur-sm opacity-80": isFileUploading,
})}
style={{
width: size.width,
aspectRatio: size.aspectRatio ?? undefined,
}}
/>
)}
{/* resize handles to be shown only when the image is selected and is not being uploaded a file */}
{editor.isEditable && selected && !isFileUploading && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{editor.isEditable && !isFileUploading && (
<>
<div
className={cn(
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
{
"opacity-100": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
onMouseDown={handleResizeStart}
/>
</>
)}
</div>
{/* if there is no src (remote or local), show the upload button */}
{(!displayedSrc || (isRemoteImageBeingUploaded && uploadEntity?.event === "insert")) && (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">{draggedInside ? "Drop image here" : "Add an image"}</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
)}
</>
);
};

View File

@@ -7,99 +7,125 @@ import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
export const CustomImageBlock: React.FC<
CustomImageNodeViewProps & {
isImageLoaded: boolean;
displayedSrc: string | null;
isLoading: boolean;
}
> = (props) => {
const { node, updateAttributes, selected, getPos, editor, displayedSrc, isImageLoaded } = props;
const { src, width, height } = node.attrs;
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
const [size, setSize] = useState<{
width: string;
height: string;
aspectRatio: number | null;
}>({
width: width || "35%",
height: height || "auto",
aspectRatio: null,
});
const [isLoading, setIsLoading] = useState(true);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
const isShimmerVisible = isLoading || !initialResizeComplete;
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
const [isResizing, setIsResizing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatioRef = useRef<number | null>(null);
useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
const closestEditorContainer = img.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
const isShimmerVisible = isLoading || !initialResizeComplete;
setEditorContainer(closestEditorContainer as HTMLElement);
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const aspectRatio = img.naturalWidth / img.naturalHeight;
const initialHeight = initialWidth / aspectRatio;
const newSize = {
width: `${Math.round(initialWidth)}px`,
height: `${Math.round(initialHeight)}px`,
};
setSize(newSize);
updateAttributes(newSize);
}
setInitialResizeComplete(true);
setIsLoading(false);
};
if (!img) {
console.error("Image reference is undefined");
return;
}
}, [width, height, updateAttributes]);
const closestEditorContainer = img.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer as HTMLElement);
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const newSize = {
width: `${Math.round(initialWidth)}px`,
height: `${Math.round(initialHeight)}px`,
aspectRatio: aspectRatio,
};
setSize(newSize);
updateAttributes(newSize);
} else {
setSize((prevSize) => ({ ...prevSize, aspectRatio }));
}
setInitialResizeComplete(true);
setIsLoading(false);
}, [width, updateAttributes]);
useLayoutEffect(() => {
setSize({ width, height });
setSize((prevSize) => ({ ...prevSize, width, height }));
}, [width, height]);
const handleResizeStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
if (containerRef.current && editorContainer) {
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
containerRect.current = containerRef.current.getBoundingClientRect();
}
},
[size, editorContainer]
);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
if (size) {
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
}
if (!aspectRatioRef.current) return;
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / aspectRatioRef.current;
const newHeight = newWidth / size.aspectRatio;
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
[size]
);
const handleResizeEnd = useCallback(() => {
if (isResizing.current) {
isResizing.current = false;
updateAttributes(size);
}
setIsResizing(false);
updateAttributes(size);
}, [size, updateAttributes]);
const handleMouseDown = useCallback(
const handleResizeStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
if (containerRef.current && editorContainer) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
},
[editorContainer]
);
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
const handleImageMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
@@ -109,57 +135,61 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
[editor, getPos]
);
useEffect(() => {
if (!editorContainer) return;
const handleMouseMove = (e: MouseEvent) => handleResize(e);
const handleMouseUp = () => handleResizeEnd();
const handleMouseLeave = () => handleResizeEnd();
editorContainer.addEventListener("mousemove", handleMouseMove);
editorContainer.addEventListener("mouseup", handleMouseUp);
editorContainer.addEventListener("mouseleave", handleMouseLeave);
return () => {
editorContainer.removeEventListener("mousemove", handleMouseMove);
editorContainer.removeEventListener("mouseup", handleMouseUp);
editorContainer.removeEventListener("mouseleave", handleMouseLeave);
};
}, [handleResize, handleResizeEnd, editorContainer]);
return (
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleMouseDown}
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio ?? undefined,
}}
>
{isShimmerVisible && (
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
)}
<img
ref={imageRef}
src={src}
src={displayedSrc ?? src}
width={size.width}
height={size.height}
onLoad={handleImageLoad}
className={cn("block rounded-md", {
hidden: isShimmerVisible,
"read-only-image": !editor.isEditable,
"blur-sm opacity-80": isLoading || !isImageLoaded,
})}
style={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio ?? undefined,
}}
/>
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
{editor.isEditable && selected && !isShimmerVisible && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{editor.isEditable && !isShimmerVisible && (
<>
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
<div
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
className={cn(
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
{
"opacity-100": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto":
!isResizing,
}
)}
onMouseDown={handleResizeStart}
/>
</>

View File

@@ -1,80 +1,74 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useMemo, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import {
CustomImageBlock,
CustomImageUploader,
UploadEntity,
UploadImageExtensionStorage,
} from "@/extensions/custom-image";
import { UploadImageExtensionStorage } from "@/extensions/custom-image";
import { CustomImageBlockNew, ImageAttributes } from "./image-block-new";
import { useUploader } from "@/hooks/use-file-upload";
import { useEditorContainerWidth } from "@/hooks/use-editor-container";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
getPos: () => number;
node: ProsemirrorNode & {
attrs: {
src: string;
width: string;
height: string;
};
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { id } = node.attrs;
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
const id = node.attrs.id as string;
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
// if the image is dropped onto the editor, we need to store the blob
const [droppedFileBlob, setDroppedFileBlob] = useState<string | undefined>(undefined);
const getUploadEntity = useCallback(
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
[editorStorage, id]
// the imageComponent's storage
const editorStorage = useMemo(
() => editor.storage.imageComponent as UploadImageExtensionStorage | undefined,
[editor.storage]
);
// the imageComponent's entity (it depicts how the image was added, either by
// dropping the image onto the editor or by inserting the image)
const uploadEntity = useMemo(() => {
if (id) {
return editorStorage?.fileMap.get(id);
}
}, [editorStorage, id]);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute
updateAttributes({ src: url });
editorStorage?.fileMap.delete(id);
// after uploading the image, we need to remove the entity from the storage
if (id) {
editorStorage?.fileMap.delete(id);
}
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const uploadFile = useCallback(
async (file: File) => {
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (error) {
console.error("Error uploading file:", error);
}
},
[editor.commands, onUpload]
);
const { loading: isFileUploading, uploadFile } = useUploader({ onUpload, editor });
useEffect(() => {
const uploadEntity = getUploadEntity();
if (uploadEntity) {
if (uploadEntity && !hasTriggeredFilePickerRef.current) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
uploadFile(uploadEntity.file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
const file = uploadEntity.file;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setDroppedFileBlob(result);
};
reader.readAsDataURL(file);
uploadFile(file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && id) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
@@ -83,39 +77,26 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
}
}, [getUploadEntity, uploadFile]);
}, [uploadEntity, uploadFile, editorStorage?.fileMap, id]);
useEffect(() => {
if (node.attrs.src) {
setIsUploaded(true);
}
}, [node.attrs.src]);
const existingFile = React.useMemo(() => {
const entity = getUploadEntity();
return entity && entity.event === "drop" ? entity.file : undefined;
}, [getUploadEntity]);
const initialEditorContainerWidth = useEditorContainerWidth(containerRef);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle>
{isUploaded ? (
<CustomImageBlock
editor={editor}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
selected={selected}
/>
) : (
<CustomImageUploader
onUpload={onUpload}
editor={editor}
fileInputRef={fileInputRef}
existingFile={existingFile}
selected={selected}
/>
)}
<div className="p-0 mx-0 my-2" data-drag-handle ref={containerRef}>
<CustomImageBlockNew
fileInputRef={fileInputRef}
uploadFile={uploadFile}
editor={editor}
droppedFileBlob={droppedFileBlob}
isFileUploading={isFileUploading}
initialEditorContainerWidth={initialEditorContainerWidth}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
selected={selected}
uploadEntity={uploadEntity}
/>
</div>
</NodeViewWrapper>
);

View File

@@ -1,6 +1,8 @@
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
import { Spinner } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common";
// hooks
@@ -24,67 +26,122 @@ export const CustomImageUploader = (props: {
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
setIsImageLoaded: (isImageLoaded: boolean) => void;
setDisplayedSrc: (displayedSrc: string | null) => void;
setIsLoading: (isLoading: boolean) => void;
}) => {
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
const { selected, onUpload, editor, fileInputRef, existingFile, setDisplayedSrc, setIsImageLoaded, setIsLoading } =
props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const imageRef = useRef<HTMLImageElement>(null);
const localRef = useRef<HTMLInputElement | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
setIsLoading(loading);
}, [loading]);
// state to track if the preview image is loaded
useEffect(() => {
if (previewUrl) {
const closestEditorContainer = imageRef.current?.closest(".editor-container");
if (closestEditorContainer) {
const editorWidth = closestEditorContainer?.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, 100);
setWidth(initialWidth);
}
}
}, [previewUrl, imageRef.current]);
// Function to preload images
const loadImage = useCallback((src: string) => {
setIsImageLoaded(false);
const img = new Image();
img.onload = () => {
setIsImageLoaded(true);
setDisplayedSrc(src);
};
img.src = src;
}, []);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
loadImage(result);
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
uploadFile(file);
}
}
},
[uploadFile]
[uploadFile, editor.storage.image, loadImage]
);
useEffect(() => {
// no need to validate as the file is already validated before the drop onto
// the editor
if (existingFile) {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setPreviewUrl(result);
loadImage(result);
};
reader.readAsDataURL(existingFile);
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
}, [existingFile, uploadFile, loadImage]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
<>
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading
? "Uploading..."
: draggedInside
? "Drop image here"
: existingFile
? "Uploading..."
: "Add an image"}
</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
</>
);
};

View File

@@ -51,6 +51,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},

View File

@@ -27,6 +27,9 @@ export const CustomReadOnlyImageExtension = () =>
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},

View File

@@ -1,8 +1,7 @@
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Image } from "@tiptap/extension-image";
// extensions
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
import { UploadImageExtensionStorage } from "@/extensions";
export const CustomImageComponentWithoutProps = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
@@ -27,6 +26,9 @@ export const CustomImageComponentWithoutProps = () =>
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
@@ -48,10 +50,6 @@ export const CustomImageComponentWithoutProps = () =>
deletedImageSet: new Map<string, boolean>(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
export default CustomImageComponentWithoutProps;

View File

@@ -1,7 +1,5 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
@@ -16,8 +14,4 @@ export const ImageExtensionWithoutProps = () =>
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});

View File

@@ -0,0 +1,37 @@
import { useState, useEffect, RefObject } from "react";
interface UseEditorContainerWidthOptions {
maxRetries?: number;
retryInterval?: number;
}
export const useEditorContainerWidth = (
containerRef: RefObject<HTMLElement>,
options: UseEditorContainerWidthOptions = {}
): number => {
const { maxRetries = 5, retryInterval = 100 } = options;
const [width, setWidth] = useState<number>(0);
useEffect(() => {
let retryCount = 0;
const checkEditorContainer = (): void => {
if (containerRef.current) {
const editorContainer = containerRef.current.closest(".editor-container") as HTMLElement | null;
if (editorContainer) {
setWidth(editorContainer.clientWidth);
return;
}
}
if (retryCount < maxRetries) {
retryCount++;
setTimeout(checkEditorContainer, retryInterval);
}
};
checkEditorContainer();
}, [containerRef, maxRetries, retryInterval]);
return width;
};