mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
6 Commits
fix/copyin
...
fix/image-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8303acb723 | ||
|
|
c18c1a47dd | ||
|
|
f8aca6a574 | ||
|
|
295fba6a0d | ||
|
|
d94046e69a | ||
|
|
432be1a506 |
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,6 +51,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ export const CustomReadOnlyImageExtension = () =>
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
37
packages/editor/src/core/hooks/use-editor-container.tsx
Normal file
37
packages/editor/src/core/hooks/use-editor-container.tsx
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user