[PE-46] fix: added aspect ratio to resizing (#5693)
* fix: added aspect ratio to resizing * fix: image loading * fix: image uploading and adding only necessary keys to listen to * fix: image aspect ratio maintainance done * fix: loading of images with uploads * fix: custom image extension loading fixed * fix: refactored all the upload logic * fix: focus detection for editor fixed * fix: drop images and inserting images cleaned up * fix: cursor focus after image node insertion and multi drop/paste range error fix * fix: image types fixed * fix: remove old images' upload code and cleaning up the code * fix: imports * fix: this reference in the plugin * fix: added file validation * fix: added error handling while reading files * fix: prevent old data to be updated in updateAttributes * fix: props types for node and image block * fix: remove unnecessary dependency * fix: seperated display message logic from ui * chore: added comments to better explain the loading states * fix: added getPos to deps * fix: remove click event on failed to load state * fix: css for error and selected state
This commit is contained in:
parent
e9d5db0093
commit
bfef0e89e0
27 changed files with 555 additions and 591 deletions
|
|
@ -18,7 +18,8 @@ interface EditorContainerProps {
|
||||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||||
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
||||||
|
|
||||||
const handleContainerClick = () => {
|
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import {
|
import {
|
||||||
|
insertImage,
|
||||||
insertTableCommand,
|
insertTableCommand,
|
||||||
setText,
|
setText,
|
||||||
toggleBlockquote,
|
toggleBlockquote,
|
||||||
|
|
@ -193,8 +194,7 @@ export const ImageItem = (editor: Editor) =>
|
||||||
key: "image",
|
key: "image",
|
||||||
name: "Image",
|
name: "Image",
|
||||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||||
command: (savedSelection: Selection | null) =>
|
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
|
||||||
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
|
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
}) as const;
|
}) as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,105 +7,169 @@ import { cn } from "@/helpers/common";
|
||||||
|
|
||||||
const MIN_SIZE = 100;
|
const MIN_SIZE = 100;
|
||||||
|
|
||||||
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
type Pixel = `${number}px`;
|
||||||
const { node, updateAttributes, selected, getPos, editor } = props;
|
|
||||||
const { src, width, height } = node.attrs;
|
|
||||||
|
|
||||||
const [size, setSize] = useState({
|
type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||||
width: width?.toString() || "35%",
|
|
||||||
height: height?.toString() || "auto",
|
export type ImageAttributes = {
|
||||||
|
src: string | null;
|
||||||
|
width: PixelAttribute<"35%" | number>;
|
||||||
|
height: PixelAttribute<"auto" | number>;
|
||||||
|
aspectRatio: number | null;
|
||||||
|
id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Size = {
|
||||||
|
width: PixelAttribute<"35%">;
|
||||||
|
height: PixelAttribute<"auto">;
|
||||||
|
aspectRatio: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
|
||||||
|
if (!value || value === defaultValue) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return `${value}px` satisfies Pixel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||||
|
imageFromFileSystem: string;
|
||||||
|
setFailedToLoadImage: (isError: boolean) => void;
|
||||||
|
editorContainer: HTMLDivElement | null;
|
||||||
|
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
|
// props
|
||||||
|
const {
|
||||||
|
node,
|
||||||
|
updateAttributes,
|
||||||
|
setFailedToLoadImage,
|
||||||
|
imageFromFileSystem,
|
||||||
|
selected,
|
||||||
|
getPos,
|
||||||
|
editor,
|
||||||
|
editorContainer,
|
||||||
|
setEditorContainer,
|
||||||
|
} = props;
|
||||||
|
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
|
||||||
|
// states
|
||||||
|
const [size, setSize] = useState<Size>({
|
||||||
|
width: ensurePixelString(width, "35%"),
|
||||||
|
height: ensurePixelString(height, "auto"),
|
||||||
|
aspectRatio: aspectRatio || 1,
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
||||||
const isShimmerVisible = isLoading || !initialResizeComplete;
|
// refs
|
||||||
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRect = useRef<DOMRect | null>(null);
|
const containerRect = useRef<DOMRect | null>(null);
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
const isResizing = useRef(false);
|
|
||||||
const aspectRatioRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const handleImageLoad = useCallback(() => {
|
||||||
if (imageRef.current) {
|
const img = imageRef.current;
|
||||||
const img = imageRef.current;
|
if (!img) return;
|
||||||
img.onload = () => {
|
let closestEditorContainer: HTMLDivElement | null = null;
|
||||||
const closestEditorContainer = img.closest(".editor-container");
|
|
||||||
if (!closestEditorContainer) {
|
|
||||||
console.error("Editor container not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditorContainer(closestEditorContainer as HTMLElement);
|
if (editorContainer) {
|
||||||
|
closestEditorContainer = editorContainer;
|
||||||
if (width === "35%") {
|
} else {
|
||||||
const editorWidth = closestEditorContainer.clientWidth;
|
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
|
||||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
if (!closestEditorContainer) {
|
||||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
console.error("Editor container not found");
|
||||||
const initialHeight = initialWidth / aspectRatio;
|
return;
|
||||||
|
|
||||||
const newSize = {
|
|
||||||
width: `${Math.round(initialWidth)}px`,
|
|
||||||
height: `${Math.round(initialHeight)}px`,
|
|
||||||
};
|
|
||||||
|
|
||||||
setSize(newSize);
|
|
||||||
updateAttributes(newSize);
|
|
||||||
}
|
|
||||||
setInitialResizeComplete(true);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [width, height, updateAttributes]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setSize({
|
|
||||||
width: width?.toString(),
|
|
||||||
height: height?.toString(),
|
|
||||||
});
|
|
||||||
}, [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]
|
if (!closestEditorContainer) {
|
||||||
);
|
console.error("Editor container not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorContainer(closestEditorContainer);
|
||||||
|
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 initialComputedSize = {
|
||||||
|
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||||
|
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSize(initialComputedSize);
|
||||||
|
updateAttributes(initialComputedSize);
|
||||||
|
} 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInitialResizeComplete(true);
|
||||||
|
}, [width, updateAttributes, editorContainer]);
|
||||||
|
|
||||||
|
// for real time resizing
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setSize((prevSize) => ({
|
||||||
|
...prevSize,
|
||||||
|
width: ensurePixelString(width),
|
||||||
|
height: ensurePixelString(height),
|
||||||
|
}));
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
const handleResize = useCallback(
|
const handleResize = useCallback(
|
||||||
(e: MouseEvent | TouchEvent) => {
|
(e: MouseEvent | TouchEvent) => {
|
||||||
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
|
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
|
||||||
|
|
||||||
if (size) {
|
|
||||||
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!aspectRatioRef.current) return;
|
|
||||||
|
|
||||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||||
|
|
||||||
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
|
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]
|
[size]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResizeEnd = useCallback(() => {
|
const handleResizeEnd = useCallback(() => {
|
||||||
if (isResizing.current) {
|
setIsResizing(false);
|
||||||
isResizing.current = false;
|
updateAttributes(size);
|
||||||
updateAttributes(size);
|
|
||||||
}
|
|
||||||
}, [size, updateAttributes]);
|
}, [size, updateAttributes]);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsResizing(true);
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRect.current = containerRef.current.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const pos = getPos();
|
const pos = getPos();
|
||||||
|
|
@ -115,65 +179,86 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||||
[editor, getPos]
|
[editor, getPos]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// 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 (!editorContainer) return;
|
// 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 handleMouseMove = (e: MouseEvent) => handleResize(e);
|
// show the image utils 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)
|
||||||
const handleMouseUp = () => handleResizeEnd();
|
const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
|
||||||
const handleMouseLeave = () => handleResizeEnd();
|
// show the preview image from the file system if the remote image's src is not set
|
||||||
|
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
|
||||||
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="group/image-component relative inline-block max-w-full"
|
className="group/image-component relative inline-block max-w-full"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleImageMouseDown}
|
||||||
style={{
|
style={{
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
aspectRatio: size.aspectRatio,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isShimmerVisible && (
|
{showImageLoader && (
|
||||||
<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
|
<img
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
src={src}
|
src={displayedImageSrc}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("Error loading image", e);
|
||||||
|
setFailedToLoadImage(true);
|
||||||
|
}}
|
||||||
width={size.width}
|
width={size.width}
|
||||||
height={size.height}
|
|
||||||
className={cn("image-component block rounded-md", {
|
className={cn("image-component block rounded-md", {
|
||||||
hidden: isShimmerVisible,
|
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
|
||||||
|
hidden: showImageLoader,
|
||||||
"read-only-image": !editor.isEditable,
|
"read-only-image": !editor.isEditable,
|
||||||
|
"blur-sm opacity-80 loading-image": !remoteImageSrc,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
aspectRatio: size.aspectRatio,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageToolbarRoot
|
{showImageUtils && (
|
||||||
containerClassName="absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
<ImageToolbarRoot
|
||||||
image={{
|
containerClassName={
|
||||||
src,
|
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||||
height: size.height,
|
}
|
||||||
width: size.width,
|
image={{
|
||||||
}}
|
src: remoteImageSrc,
|
||||||
/>
|
aspectRatio: size.aspectRatio,
|
||||||
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
|
height: size.height,
|
||||||
{editor.isEditable && (
|
width: size.width,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selected && displayedImageSrc === remoteImageSrc && (
|
||||||
|
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
|
||||||
|
)}
|
||||||
|
{showImageUtils && (
|
||||||
<>
|
<>
|
||||||
<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
|
<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}
|
onMouseDown={handleResizeStart}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||||
import { Editor, NodeViewWrapper } from "@tiptap/react";
|
import { Editor, NodeViewWrapper } from "@tiptap/react";
|
||||||
// extensions
|
// extensions
|
||||||
import {
|
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||||
CustomImageBlock,
|
|
||||||
CustomImageUploader,
|
|
||||||
UploadEntity,
|
|
||||||
UploadImageExtensionStorage,
|
|
||||||
} from "@/extensions/custom-image";
|
|
||||||
|
|
||||||
export type CustomImageNodeViewProps = {
|
export type CustomImageNodeViewProps = {
|
||||||
getPos: () => number;
|
getPos: () => number;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
node: ProsemirrorNode & {
|
node: ProsemirrorNode & {
|
||||||
attrs: {
|
attrs: ImageAttributes;
|
||||||
src: string;
|
|
||||||
width: number | string;
|
|
||||||
height: number | string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
updateAttributes: (attrs: Record<string, any>) => void;
|
updateAttributes: (attrs: Record<string, any>) => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|
@ -26,94 +17,60 @@ export type CustomImageNodeViewProps = {
|
||||||
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const [isUploaded, setIsUploaded] = useState(false);
|
||||||
const hasTriggeredFilePickerRef = useRef(false);
|
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
||||||
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
|
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
|
||||||
|
|
||||||
const id = node.attrs.id as string;
|
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
|
||||||
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
|
const imageComponentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getUploadEntity = useCallback(
|
|
||||||
(): UploadEntity | undefined => 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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[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]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const uploadEntity = getUploadEntity();
|
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||||
|
if (!closestEditorContainer) {
|
||||||
if (uploadEntity) {
|
console.error("Editor container not found");
|
||||||
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
|
return;
|
||||||
uploadFile(uploadEntity.file);
|
|
||||||
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
|
|
||||||
const entity = editorStorage?.fileMap.get(id);
|
|
||||||
if (entity && entity.hasOpenedFileInputOnce) return;
|
|
||||||
fileInputRef.current.click();
|
|
||||||
hasTriggeredFilePickerRef.current = true;
|
|
||||||
if (!entity) return;
|
|
||||||
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [getUploadEntity, uploadFile]);
|
|
||||||
|
|
||||||
|
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (node.attrs.src) {
|
const remoteImageSrc = node.attrs.src;
|
||||||
|
if (remoteImageSrc) {
|
||||||
setIsUploaded(true);
|
setIsUploaded(true);
|
||||||
|
setImageFromFileSystem(undefined);
|
||||||
|
} else {
|
||||||
|
setIsUploaded(false);
|
||||||
}
|
}
|
||||||
}, [node.attrs.src]);
|
}, [node.attrs.src]);
|
||||||
|
|
||||||
const existingFile = React.useMemo(() => {
|
|
||||||
const entity = getUploadEntity();
|
|
||||||
return entity && entity.event === "drop" ? entity.file : undefined;
|
|
||||||
}, [getUploadEntity]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
<div className="p-0 mx-0 my-2" data-drag-handle>
|
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||||
{isUploaded ? (
|
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||||
<CustomImageBlock
|
<CustomImageBlock
|
||||||
|
imageFromFileSystem={imageFromFileSystem}
|
||||||
|
editorContainer={editorContainer}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
getPos={getPos}
|
getPos={getPos}
|
||||||
node={node}
|
node={node}
|
||||||
updateAttributes={updateAttributes}
|
setEditorContainer={setEditorContainer}
|
||||||
|
setFailedToLoadImage={setFailedToLoadImage}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
updateAttributes={updateAttributes}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CustomImageUploader
|
<CustomImageUploader
|
||||||
onUpload={onUpload}
|
|
||||||
editor={editor}
|
editor={editor}
|
||||||
fileInputRef={fileInputRef}
|
failedToLoadImage={failedToLoadImage}
|
||||||
existingFile={existingFile}
|
getPos={getPos}
|
||||||
|
loadImageFromFileSystem={setImageFromFileSystem}
|
||||||
|
node={node}
|
||||||
|
setIsUploaded={setIsUploaded}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
updateAttributes={updateAttributes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,111 @@
|
||||||
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
|
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { ImageIcon } from "lucide-react";
|
import { ImageIcon } from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
|
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
|
||||||
// plugins
|
// plugins
|
||||||
import { isFileValid } from "@/plugins/image";
|
import { isFileValid } from "@/plugins/image";
|
||||||
|
// extensions
|
||||||
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
|
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomImageUploader = (props: {
|
export const CustomImageUploader = (props: {
|
||||||
onUpload: (url: string) => void;
|
failedToLoadImage: boolean;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
fileInputRef: RefType;
|
|
||||||
existingFile?: File;
|
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
loadImageFromFileSystem: (file: string) => void;
|
||||||
|
setIsUploaded: (isUploaded: boolean) => void;
|
||||||
|
node: ProsemirrorNode & {
|
||||||
|
attrs: ImageAttributes;
|
||||||
|
};
|
||||||
|
updateAttributes: (attrs: Record<string, any>) => void;
|
||||||
|
getPos: () => number;
|
||||||
}) => {
|
}) => {
|
||||||
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
|
const {
|
||||||
const { loading, uploadFile } = useUploader({ onUpload, editor });
|
selected,
|
||||||
const { handleUploadClick, ref: internalRef } = useFileUpload();
|
failedToLoadImage,
|
||||||
|
editor,
|
||||||
|
loadImageFromFileSystem,
|
||||||
|
node,
|
||||||
|
setIsUploaded,
|
||||||
|
updateAttributes,
|
||||||
|
getPos,
|
||||||
|
} = props;
|
||||||
|
// ref
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const hasTriggeredFilePickerRef = useRef(false);
|
||||||
|
const imageEntityId = node.attrs.id;
|
||||||
|
|
||||||
|
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
||||||
|
|
||||||
|
const onUpload = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
if (url) {
|
||||||
|
setIsUploaded(true);
|
||||||
|
// Update the node view's src attribute post upload
|
||||||
|
updateAttributes({ src: url });
|
||||||
|
imageComponentImageFileMap?.delete(imageEntityId);
|
||||||
|
|
||||||
|
const pos = getPos();
|
||||||
|
// get current node
|
||||||
|
const getCurrentSelection = editor.state.selection;
|
||||||
|
const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from);
|
||||||
|
|
||||||
|
// only if the cursor is at the current image component, manipulate
|
||||||
|
// the cursor position
|
||||||
|
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
|
||||||
|
// control cursor position after upload
|
||||||
|
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
||||||
|
|
||||||
|
if (nextNode && nextNode.type.name === "paragraph") {
|
||||||
|
// If there is a paragraph node after the image component, move the focus to the next node
|
||||||
|
editor.commands.setTextSelection(pos + 1);
|
||||||
|
} else {
|
||||||
|
// create a new paragraph after the image component post upload
|
||||||
|
editor.commands.createParagraphNear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||||
|
);
|
||||||
|
// 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 });
|
||||||
|
|
||||||
const localRef = useRef<HTMLInputElement | null>(null);
|
// the meta data of the image component
|
||||||
|
const meta = useMemo(
|
||||||
|
() => imageComponentImageFileMap?.get(imageEntityId),
|
||||||
|
[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(() => {
|
||||||
|
if (meta) {
|
||||||
|
if (meta.event === "drop" && "file" in meta) {
|
||||||
|
uploadFile(meta.file);
|
||||||
|
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
|
||||||
|
if (meta.hasOpenedFileInputOnce) return;
|
||||||
|
fileInputRef.current.click();
|
||||||
|
hasTriggeredFilePickerRef.current = true;
|
||||||
|
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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(
|
const onFileChange = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -44,13 +119,22 @@ export const CustomImageUploader = (props: {
|
||||||
[uploadFile]
|
[uploadFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const getDisplayMessage = useCallback(() => {
|
||||||
// no need to validate as the file is already validated before the drop onto
|
const isUploading = isImageBeingUploaded || existingFile;
|
||||||
// the editor
|
if (failedToLoadImage) {
|
||||||
if (existingFile) {
|
return "Error loading image";
|
||||||
uploadFile(existingFile);
|
|
||||||
}
|
}
|
||||||
}, [existingFile, uploadFile]);
|
|
||||||
|
if (isUploading) {
|
||||||
|
return "Uploading...";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedInside) {
|
||||||
|
return "Drop image here";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Add an image";
|
||||||
|
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -58,28 +142,27 @@ export const CustomImageUploader = (props: {
|
||||||
"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",
|
"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,
|
"bg-custom-background-80 text-custom-text-200": draggedInside,
|
||||||
},
|
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
|
||||||
{
|
selected,
|
||||||
"text-custom-primary-200 bg-custom-primary-100/10": selected,
|
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
|
||||||
|
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragEnter}
|
onDragOver={onDragEnter}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={handleUploadClick}
|
onClick={() => {
|
||||||
|
if (!failedToLoadImage) {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ImageIcon className="size-4" />
|
<ImageIcon className="size-4" />
|
||||||
<div className="text-base font-medium">
|
<div className="text-base font-medium">{getDisplayMessage()}</div>
|
||||||
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
className="size-0 overflow-hidden"
|
className="size-0 overflow-hidden"
|
||||||
ref={(element) => {
|
ref={fileInputRef}
|
||||||
localRef.current = element;
|
|
||||||
assignRef(fileInputRef, element);
|
|
||||||
assignRef(internalRef as RefType, element);
|
|
||||||
}}
|
|
||||||
hidden
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.jpeg,.png,.webp"
|
accept=".jpg,.jpeg,.png,.webp"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
@ -8,6 +8,7 @@ type Props = {
|
||||||
src: string;
|
src: string;
|
||||||
height: string;
|
height: string;
|
||||||
width: string;
|
width: string;
|
||||||
|
aspectRatio: number;
|
||||||
};
|
};
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggleFullScreenMode: (val: boolean) => void;
|
toggleFullScreenMode: (val: boolean) => void;
|
||||||
|
|
@ -17,13 +18,13 @@ const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
|
||||||
|
|
||||||
export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||||
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
|
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
|
||||||
const { height, src, width } = image;
|
const { src, width, aspectRatio } = image;
|
||||||
// states
|
// states
|
||||||
const [magnification, setMagnification] = useState(1);
|
const [magnification, setMagnification] = useState(1);
|
||||||
|
// refs
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
// derived values
|
// derived values
|
||||||
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
|
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
|
||||||
const heightInNumber = useMemo(() => Number(height?.replace("px", "")), [height]);
|
|
||||||
const aspectRatio = useMemo(() => widthInNumber / heightInNumber, [heightInNumber, widthInNumber]);
|
|
||||||
// close handler
|
// close handler
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
toggleFullScreenMode(false);
|
toggleFullScreenMode(false);
|
||||||
|
|
@ -55,25 +56,35 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||||
// keydown handler
|
// keydown handler
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
e.preventDefault();
|
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
if (e.key === "Escape") handleClose();
|
e.stopPropagation();
|
||||||
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
|
|
||||||
if (e.key === "-") handleDecreaseMagnification();
|
if (e.key === "Escape") handleClose();
|
||||||
|
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
|
||||||
|
if (e.key === "-") handleDecreaseMagnification();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
|
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
|
||||||
);
|
);
|
||||||
|
// click outside handler
|
||||||
|
const handleClickOutside = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
if (modalRef.current && e.target === modalRef.current) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleClose]
|
||||||
|
);
|
||||||
// register keydown listener
|
// register keydown listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
if (isFullScreenEnabled) {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
if (!isFullScreenEnabled) {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [handleKeyDown, isFullScreenEnabled]);
|
}, [handleKeyDown, isFullScreenEnabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -86,7 +97,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative size-full grid place-items-center">
|
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
|
|
@ -103,7 +114,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20">
|
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type Props = {
|
||||||
src: string;
|
src: string;
|
||||||
height: string;
|
height: string;
|
||||||
width: string;
|
width: string;
|
||||||
|
aspectRatio: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mergeAttributes } from "@tiptap/core";
|
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||||
import { Image } from "@tiptap/extension-image";
|
import { Image } from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
@ -11,15 +11,24 @@ import { TFileHandler } from "@/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
|
|
||||||
|
export type InsertImageComponentProps = {
|
||||||
|
file?: File;
|
||||||
|
pos?: number;
|
||||||
|
event: "insert" | "drop";
|
||||||
|
};
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
imageComponent: {
|
imageComponent: {
|
||||||
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
|
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||||
|
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
|
||||||
|
|
||||||
export interface UploadImageExtensionStorage {
|
export interface UploadImageExtensionStorage {
|
||||||
fileMap: Map<string, UploadEntity>;
|
fileMap: Map<string, UploadEntity>;
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +60,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||||
["id"]: {
|
["id"]: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -101,12 +113,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||||
return {
|
return {
|
||||||
fileMap: new Map(),
|
fileMap: new Map(),
|
||||||
deletedImageSet: new Map<string, boolean>(),
|
deletedImageSet: new Map<string, boolean>(),
|
||||||
|
uploadInProgress: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
setImageUpload:
|
insertImageComponent:
|
||||||
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
// Early return if there's an invalid file being dropped
|
// Early return if there's an invalid file being dropped
|
||||||
|
|
@ -117,15 +130,21 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||||
// generate a unique id for the image to keep track of dropped
|
// generate a unique id for the image to keep track of dropped
|
||||||
// files' file data
|
// files' file data
|
||||||
const fileId = uuidv4();
|
const fileId = uuidv4();
|
||||||
if (props?.event === "drop" && props.file) {
|
|
||||||
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
|
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||||
file: props.file,
|
|
||||||
event: props.event,
|
if (imageComponentImageFileMap) {
|
||||||
});
|
if (props?.event === "drop" && props.file) {
|
||||||
} else if (props.event === "insert") {
|
imageComponentImageFileMap.set(fileId, {
|
||||||
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
|
file: props.file,
|
||||||
event: props.event,
|
event: props.event,
|
||||||
});
|
});
|
||||||
|
} else if (props.event === "insert") {
|
||||||
|
imageComponentImageFileMap.set(fileId, {
|
||||||
|
event: props.event,
|
||||||
|
hasOpenedFileInputOnce: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const attributes = {
|
const attributes = {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ export const CustomReadOnlyImageExtension = () =>
|
||||||
["id"]: {
|
["id"]: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Extension } from "@tiptap/core";
|
import { Extension, Editor } from "@tiptap/core";
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ export const DropHandlerExtension = () =>
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor;
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: new PluginKey("drop-handler-plugin"),
|
key: new PluginKey("drop-handler-plugin"),
|
||||||
|
|
@ -20,15 +21,9 @@ export const DropHandlerExtension = () =>
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
const pos = view.state.selection.from;
|
const pos = view.state.selection.from;
|
||||||
imageFiles.forEach((file, index) => {
|
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||||
this.editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setImageUpload({ file, pos: pos + index, event: "drop" })
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
@ -45,15 +40,8 @@ export const DropHandlerExtension = () =>
|
||||||
});
|
});
|
||||||
|
|
||||||
if (coordinates) {
|
if (coordinates) {
|
||||||
imageFiles.forEach((file, index) => {
|
const pos = coordinates.pos;
|
||||||
setTimeout(() => {
|
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||||
this.editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
|
|
||||||
.run();
|
|
||||||
}, index * 100); // Slight delay between insertions
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -65,3 +53,40 @@ export const DropHandlerExtension = () =>
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insertImages = async ({
|
||||||
|
editor,
|
||||||
|
files,
|
||||||
|
initialPos,
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
files: File[];
|
||||||
|
initialPos: number;
|
||||||
|
event: "insert" | "drop";
|
||||||
|
}) => {
|
||||||
|
let pos = initialPos;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// safe insertion
|
||||||
|
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 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error while ${event}ing image:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next position
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ export const CoreEditorExtensions = ({
|
||||||
placeholder: ({ editor, node }) => {
|
placeholder: ({ editor, node }) => {
|
||||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||||
|
|
||||||
// if (editor.storage.image.uploadInProgress) return "";
|
if (editor.storage.imageComponent.uploadInProgress) return "";
|
||||||
|
|
||||||
const shouldHidePlaceholder =
|
const shouldHidePlaceholder =
|
||||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { mergeAttributes } from "@tiptap/core";
|
import { mergeAttributes } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
import { Image } from "@tiptap/extension-image";
|
import { Image } from "@tiptap/extension-image";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
|
import { UploadImageExtensionStorage } from "@/extensions";
|
||||||
|
|
||||||
export const CustomImageComponentWithoutProps = () =>
|
export const CustomImageComponentWithoutProps = () =>
|
||||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||||
|
|
@ -27,6 +26,9 @@ export const CustomImageComponentWithoutProps = () =>
|
||||||
["id"]: {
|
["id"]: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -48,10 +50,6 @@ export const CustomImageComponentWithoutProps = () =>
|
||||||
deletedImageSet: new Map<string, boolean>(),
|
deletedImageSet: new Map<string, boolean>(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(CustomImageNode);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default CustomImageComponentWithoutProps;
|
export default CustomImageComponentWithoutProps;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import ImageExt from "@tiptap/extension-image";
|
import ImageExt from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
// extensions
|
|
||||||
import { CustomImageNode } from "@/extensions";
|
|
||||||
|
|
||||||
export const ImageExtensionWithoutProps = () =>
|
export const ImageExtensionWithoutProps = () =>
|
||||||
ImageExt.extend({
|
ImageExt.extend({
|
||||||
|
|
@ -16,8 +13,4 @@ export const ImageExtensionWithoutProps = () =>
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(CustomImageNode);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
toggleHeadingFour,
|
toggleHeadingFour,
|
||||||
toggleHeadingFive,
|
toggleHeadingFive,
|
||||||
toggleHeadingSix,
|
toggleHeadingSix,
|
||||||
|
insertImage,
|
||||||
} from "@/helpers/editor-commands";
|
} from "@/helpers/editor-commands";
|
||||||
// types
|
// types
|
||||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||||
|
|
@ -226,9 +227,7 @@ const getSuggestionItems =
|
||||||
icon: <ImageIcon className="size-3.5" />,
|
icon: <ImageIcon className="size-3.5" />,
|
||||||
description: "Insert an image",
|
description: "Insert an image",
|
||||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||||
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "divider",
|
key: "divider",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { Editor, Range } from "@tiptap/core";
|
import { Editor, Range } from "@tiptap/core";
|
||||||
import { Selection } from "@tiptap/pm/state";
|
|
||||||
// extensions
|
// extensions
|
||||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||||
// helpers
|
// helpers
|
||||||
import { findTableAncestor } from "@/helpers/common";
|
import { findTableAncestor } from "@/helpers/common";
|
||||||
// plugins
|
|
||||||
import { startImageUpload } from "@/plugins/image";
|
|
||||||
// types
|
// types
|
||||||
import { UploadImage } from "@/types";
|
import { InsertImageComponentProps } from "@/extensions";
|
||||||
|
|
||||||
export const setText = (editor: Editor, range?: Range) => {
|
export const setText = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||||
|
|
@ -129,6 +126,27 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const insertImage = ({
|
||||||
|
editor,
|
||||||
|
event,
|
||||||
|
pos,
|
||||||
|
file,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
event: "insert" | "drop";
|
||||||
|
pos?: number | null;
|
||||||
|
file?: File;
|
||||||
|
range?: Range;
|
||||||
|
}) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
|
const imageOptions: InsertImageComponentProps = { event };
|
||||||
|
if (pos) imageOptions.pos = pos;
|
||||||
|
if (file) imageOptions.file = file;
|
||||||
|
return editor?.chain().focus().insertImageComponent(imageOptions).run();
|
||||||
|
};
|
||||||
|
|
||||||
export const unsetLinkEditor = (editor: Editor) => {
|
export const unsetLinkEditor = (editor: Editor) => {
|
||||||
editor.chain().focus().unsetLink().run();
|
editor.chain().focus().unsetLink().run();
|
||||||
};
|
};
|
||||||
|
|
@ -136,23 +154,3 @@ export const unsetLinkEditor = (editor: Editor) => {
|
||||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||||
editor.chain().focus().setLink({ href: url }).run();
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertImageCommand = (
|
|
||||||
editor: Editor,
|
|
||||||
uploadFile: UploadImage,
|
|
||||||
savedSelection?: Selection | null,
|
|
||||||
range?: Range
|
|
||||||
) => {
|
|
||||||
if (range) editor.chain().focus().deleteRange(range).run();
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "file";
|
|
||||||
input.accept = ".jpeg, .jpg, .png, .webp";
|
|
||||||
input.onchange = async () => {
|
|
||||||
if (input.files?.length) {
|
|
||||||
const file = input.files[0];
|
|
||||||
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
|
|
||||||
startImageUpload(editor, file, editor.view, pos, uploadFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
value,
|
value,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
|
|
||||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||||
// refs
|
// refs
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
|
|
@ -106,7 +107,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
// value is null when intentionally passed where syncing is not yet
|
// value is null when intentionally passed where syncing is not yet
|
||||||
// supported and value is undefined when the data from swr is not populated
|
// supported and value is undefined when the data from swr is not populated
|
||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
|
||||||
try {
|
try {
|
||||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||||
const currentSavedSelection = savedSelectionRef.current;
|
const currentSavedSelection = savedSelectionRef.current;
|
||||||
|
|
@ -203,7 +204,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
scrollSummary(editorRef.current, marking);
|
scrollSummary(editorRef.current, marking);
|
||||||
},
|
},
|
||||||
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
|
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
|
||||||
setFocusAtPosition: (position: number) => {
|
setFocusAtPosition: (position: number) => {
|
||||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||||
console.error("Editor reference is not available or has been destroyed.");
|
console.error("Editor reference is not available or has been destroyed.");
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,48 @@
|
||||||
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { isFileValid } from "@/plugins/image";
|
import { isFileValid } from "@/plugins/image";
|
||||||
|
|
||||||
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
|
export const useUploader = ({
|
||||||
const [loading, setLoading] = useState(false);
|
onUpload,
|
||||||
|
editor,
|
||||||
|
loadImageFromFileSystem,
|
||||||
|
}: {
|
||||||
|
onUpload: (url: string) => void;
|
||||||
|
editor: Editor;
|
||||||
|
loadImageFromFileSystem: (file: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
setLoading(true);
|
const setImageUploadInProgress = (isUploading: boolean) => {
|
||||||
|
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||||
|
};
|
||||||
|
setImageUploadInProgress(true);
|
||||||
|
setUploading(true);
|
||||||
|
const fileNameTrimmed = trimFileName(file.name);
|
||||||
|
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||||
|
const isValid = isFileValid(fileWithTrimmedName);
|
||||||
|
if (!isValid) {
|
||||||
|
setImageUploadInProgress(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result) {
|
||||||
|
loadImageFromFileSystem(reader.result as string);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to read the file: reader.result is null");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
console.error("Error reading file");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(fileWithTrimmedName);
|
||||||
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
||||||
// here for now
|
// here for now
|
||||||
const url: string = await editor?.commands.uploadImage(file);
|
const url: string = await editor?.commands.uploadImage(fileWithTrimmedName);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error("Something went wrong while uploading the image");
|
throw new Error("Something went wrong while uploading the image");
|
||||||
|
|
@ -21,24 +52,17 @@ export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => v
|
||||||
console.log(errPayload);
|
console.log(errPayload);
|
||||||
const error = errPayload?.response?.data?.error || "Something went wrong";
|
const error = errPayload?.response?.data?.error || "Something went wrong";
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setImageUploadInProgress(false);
|
||||||
|
setUploading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
},
|
},
|
||||||
[onUpload, editor]
|
[onUpload]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { loading, uploadFile };
|
return { uploading, uploadFile };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFileUpload = () => {
|
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleUploadClick = useCallback(() => {
|
|
||||||
fileInput.current?.click();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { ref: fileInput, handleUploadClick };
|
|
||||||
};
|
|
||||||
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
|
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||||
|
|
@ -90,10 +114,9 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
|
||||||
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
|
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
const isValid = isFileValid(file);
|
uploader(file);
|
||||||
if (isValid) {
|
} else {
|
||||||
uploader(file);
|
console.error("No file found");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploader]
|
[uploader]
|
||||||
|
|
@ -109,3 +132,14 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
|
||||||
|
|
||||||
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
|
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function trimFileName(fileName: string, maxLength = 100) {
|
||||||
|
if (fileName.length > maxLength) {
|
||||||
|
const extension = fileName.split(".").pop();
|
||||||
|
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
||||||
|
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
||||||
|
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
// plugins
|
|
||||||
import { findPlaceholder, isFileValid, removePlaceholder, uploadKey } from "@/plugins/image";
|
|
||||||
// types
|
|
||||||
import { UploadImage } from "@/types";
|
|
||||||
|
|
||||||
export async function startImageUpload(
|
|
||||||
editor: Editor,
|
|
||||||
file: File,
|
|
||||||
view: EditorView,
|
|
||||||
pos: number | null,
|
|
||||||
uploadFile: UploadImage
|
|
||||||
) {
|
|
||||||
editor.storage.image.uploadInProgress = true;
|
|
||||||
|
|
||||||
if (!isFileValid(file)) {
|
|
||||||
editor.storage.image.uploadInProgress = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = uuidv4();
|
|
||||||
|
|
||||||
const tr = view.state.tr;
|
|
||||||
if (!tr.selection.empty) tr.deleteSelection();
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
reader.onload = () => {
|
|
||||||
tr.setMeta(uploadKey, {
|
|
||||||
add: {
|
|
||||||
id,
|
|
||||||
pos,
|
|
||||||
src: reader.result,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
view.dispatch(tr);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle FileReader errors
|
|
||||||
reader.onerror = (error) => {
|
|
||||||
console.error("FileReader error: ", error);
|
|
||||||
removePlaceholder(editor, view, id);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNameTrimmed = trimFileName(file.name);
|
|
||||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
|
||||||
|
|
||||||
const resolvedPos = view.state.doc.resolve(pos ?? 0);
|
|
||||||
const nodeBefore = resolvedPos.nodeBefore;
|
|
||||||
|
|
||||||
// if the image is at the start of the line i.e. when nodeBefore is null
|
|
||||||
if (nodeBefore === null) {
|
|
||||||
if (pos) {
|
|
||||||
// so that the image is not inserted at the next line, else incase the
|
|
||||||
// image is inserted at any line where there's some content, the
|
|
||||||
// position is kept as it is to be inserted at the next line
|
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
view.focus();
|
|
||||||
|
|
||||||
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
|
|
||||||
|
|
||||||
if (src == null) {
|
|
||||||
throw new Error("Resolved image URL is undefined.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { schema } = view.state;
|
|
||||||
pos = findPlaceholder(view.state, id);
|
|
||||||
|
|
||||||
if (pos == null) {
|
|
||||||
editor.storage.image.uploadInProgress = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const imageSrc = typeof src === "object" ? reader.result : src;
|
|
||||||
|
|
||||||
const node = schema.nodes.image.create({ src: imageSrc });
|
|
||||||
|
|
||||||
if (pos < 0 || pos > view.state.doc.content.size) {
|
|
||||||
throw new Error("Invalid position to insert the image node.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert the image node at the position of the placeholder and remove the placeholder
|
|
||||||
const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } });
|
|
||||||
|
|
||||||
view.dispatch(transaction);
|
|
||||||
|
|
||||||
editor.storage.image.uploadInProgress = false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in uploading and inserting image: ", error);
|
|
||||||
removePlaceholder(editor, view, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise<string | undefined> {
|
|
||||||
try {
|
|
||||||
const imageUrl = await uploadFile(file);
|
|
||||||
|
|
||||||
if (imageUrl == null) {
|
|
||||||
throw new Error("Image URL is undefined.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.src = imageUrl;
|
|
||||||
image.onload = () => {
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
image.onerror = (error) => {
|
|
||||||
console.error("Error in loading image: ", error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return imageUrl;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in uploading image: ", error);
|
|
||||||
// throw error to remove the placeholder
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimFileName(fileName: string, maxLength = 100) {
|
|
||||||
if (fileName.length > maxLength) {
|
|
||||||
const extension = fileName.split(".").pop();
|
|
||||||
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
|
||||||
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
|
||||||
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,4 @@ export * from "./types";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./constants";
|
export * from "./constants";
|
||||||
export * from "./delete-image";
|
export * from "./delete-image";
|
||||||
export * from "./image-upload-handler";
|
|
||||||
export * from "./restore-image";
|
export * from "./restore-image";
|
||||||
export * from "./upload-image";
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Plugin } from "@tiptap/pm/state";
|
|
||||||
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
|
||||||
// plugins
|
|
||||||
import { removePlaceholder, uploadKey } from "@/plugins/image";
|
|
||||||
|
|
||||||
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
|
|
||||||
let currentView: EditorView | null = null;
|
|
||||||
|
|
||||||
const createPlaceholder = (src: string): HTMLElement => {
|
|
||||||
const placeholder = document.createElement("div");
|
|
||||||
placeholder.setAttribute("class", "img-placeholder");
|
|
||||||
const image = document.createElement("img");
|
|
||||||
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
|
|
||||||
image.src = src;
|
|
||||||
placeholder.appendChild(image);
|
|
||||||
|
|
||||||
return placeholder;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCancelButton = (id: string): HTMLButtonElement => {
|
|
||||||
const cancelButton = document.createElement("button");
|
|
||||||
cancelButton.type = "button";
|
|
||||||
cancelButton.style.position = "absolute";
|
|
||||||
cancelButton.style.right = "3px";
|
|
||||||
cancelButton.style.top = "3px";
|
|
||||||
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
|
|
||||||
|
|
||||||
cancelButton.onclick = () => {
|
|
||||||
if (currentView) {
|
|
||||||
cancelUploadImage?.();
|
|
||||||
removePlaceholder(editor, currentView, id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create an SVG element from the SVG string
|
|
||||||
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
|
|
||||||
|
|
||||||
cancelButton.appendChild(svgElement);
|
|
||||||
|
|
||||||
return cancelButton;
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Plugin({
|
|
||||||
key: uploadKey,
|
|
||||||
view(editorView) {
|
|
||||||
currentView = editorView;
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
currentView = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
init() {
|
|
||||||
return DecorationSet.empty;
|
|
||||||
},
|
|
||||||
apply(tr, set) {
|
|
||||||
set = set.map(tr.mapping, tr.doc);
|
|
||||||
const action = tr.getMeta(uploadKey);
|
|
||||||
if (action && action.add) {
|
|
||||||
const { id, pos, src } = action.add;
|
|
||||||
|
|
||||||
const placeholder = createPlaceholder(src);
|
|
||||||
const cancelButton = createCancelButton(id);
|
|
||||||
|
|
||||||
placeholder.appendChild(cancelButton);
|
|
||||||
|
|
||||||
const deco = Decoration.widget(pos, placeholder, {
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
set = set.add(tr.doc, [deco]);
|
|
||||||
} else if (action && action.remove) {
|
|
||||||
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return this.getState(state);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
export * from "./placeholder";
|
|
||||||
export * from "./validate-file";
|
export * from "./validate-file";
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { EditorState } from "@tiptap/pm/state";
|
|
||||||
import { DecorationSet, EditorView } from "@tiptap/pm/view";
|
|
||||||
// plugins
|
|
||||||
import { uploadKey } from "@/plugins/image";
|
|
||||||
|
|
||||||
export function findPlaceholder(state: EditorState, id: string): number | null {
|
|
||||||
const decos = uploadKey.getState(state) as DecorationSet;
|
|
||||||
const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id);
|
|
||||||
return found.length ? found[0].from : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removePlaceholder(editor: Editor, view: EditorView, id: string) {
|
|
||||||
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } });
|
|
||||||
view.dispatch(removePlaceholderTr);
|
|
||||||
editor.storage.image.uploadInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
&:not(.read-only-image) {
|
&:not(.read-only-image):not(.loading-image) {
|
||||||
transition: filter 0.1s ease-in-out;
|
transition: filter 0.1s ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||||
suggestions: mentionSuggestions,
|
suggestions: mentionSuggestions,
|
||||||
}}
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
containerClassName={cn("relative pl-3", containerClassName)}
|
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
issueOperations={issueOperations}
|
issueOperations={issueOperations}
|
||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
containerClassName="-ml-3 !mb-6 border-none"
|
containerClassName="-ml-3 border-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
issueOperations={issueOperations}
|
issueOperations={issueOperations}
|
||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
containerClassName="-ml-3 !mb-6 border-none"
|
containerClassName="-ml-3 border-none"
|
||||||
/>
|
/>
|
||||||
{/* )} */}
|
{/* )} */}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
issueOperations={issueOperations}
|
issueOperations={issueOperations}
|
||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
containerClassName="-ml-3 !mb-6 border-none"
|
containerClassName="-ml-3 border-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue