Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8bb5dad
fix: added aspect ratio to resizing
Palanikannan1437 Sep 24, 2024
2a11fbe
Merge branch 'preview' into fix/image-resize-aspect
Palanikannan1437 Sep 24, 2024
ec8bfb2
fix: image loading
Palanikannan1437 Sep 25, 2024
e2b453e
fix: image uploading and adding only necessary keys to listen to
Palanikannan1437 Sep 25, 2024
fc834e0
fix: image aspect ratio maintainance done
Palanikannan1437 Sep 25, 2024
bf10ddc
fix: loading of images with uploads
Palanikannan1437 Sep 25, 2024
b029542
fix: custom image extension loading fixed
Palanikannan1437 Sep 26, 2024
171a2ce
fix: refactored all the upload logic
Palanikannan1437 Sep 26, 2024
1e68faf
fix: focus detection for editor fixed
Palanikannan1437 Sep 26, 2024
df973e6
fix: drop images and inserting images cleaned up
Palanikannan1437 Sep 26, 2024
6815990
fix: cursor focus after image node insertion and multi drop/paste ran…
Palanikannan1437 Sep 27, 2024
0e51fd0
fix: image types fixed
Palanikannan1437 Sep 27, 2024
03be5a8
Merge branch 'preview' into fix/image-resize-aspect
Palanikannan1437 Sep 30, 2024
46a3062
fix: remove old images' upload code and cleaning up the code
Palanikannan1437 Sep 30, 2024
9788599
fix: imports
Palanikannan1437 Sep 30, 2024
1c0d2fb
fix: this reference in the plugin
Palanikannan1437 Sep 30, 2024
808e4e9
fix: added file validation
Palanikannan1437 Sep 30, 2024
60515d2
fix: added error handling while reading files
Palanikannan1437 Sep 30, 2024
0a4c2c7
fix: prevent old data to be updated in updateAttributes
Palanikannan1437 Sep 30, 2024
daf60c4
fix: props types for node and image block
Palanikannan1437 Sep 30, 2024
715f022
fix: remove unnecessary dependency
Palanikannan1437 Sep 30, 2024
709b60c
fix: seperated display message logic from ui
Palanikannan1437 Sep 30, 2024
b7ad51f
chore: added comments to better explain the loading states
Palanikannan1437 Sep 30, 2024
8d337d0
fix: added getPos to deps
Palanikannan1437 Sep 30, 2024
0109f4e
fix: remove click event on failed to load state
Palanikannan1437 Sep 30, 2024
823e11b
fix: css for error and selected state
Palanikannan1437 Sep 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ interface EditorContainerProps {
export const EditorContainer: FC<EditorContainerProps> = (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.isEditable) return;
try {
Expand Down
4 changes: 2 additions & 2 deletions packages/editor/src/core/components/menus/menu-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "lucide-react";
// helpers
import {
insertImage,
insertTableCommand,
setText,
toggleBlockquote,
Expand Down Expand Up @@ -193,8 +194,7 @@ export const ImageItem = (editor: Editor) =>
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,105 +7,169 @@ import { cn } from "@/helpers/common";

const MIN_SIZE = 100;

export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
const { src, width, height } = node.attrs;
type Pixel = `${number}px`;

const [size, setSize] = useState({
width: width?.toString() || "35%",
height: height?.toString() || "auto",
type PixelAttribute<TDefault> = Pixel | TDefault;

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 isShimmerVisible = isLoading || !initialResizeComplete;
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);

// refs
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;
}

setEditorContainer(closestEditorContainer as HTMLElement);

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);
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
let closestEditorContainer: HTMLDivElement | null = null;

if (editorContainer) {
closestEditorContainer = editorContainer;
} else {
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
}
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;
});
}
}, [width, height, updateAttributes]);
setInitialResizeComplete(true);
}, [width, updateAttributes, editorContainer]);

// for real time resizing
useLayoutEffect(() => {
setSize({
width: width?.toString(),
height: height?.toString(),
});
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(width),
height: ensurePixelString(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) {
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.stopPropagation();
const pos = getPos();
Expand All @@ -115,65 +179,86 @@ 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]);
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
// 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 showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;

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,
}}
>
{isShimmerVisible && (
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
{showImageLoader && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
)}
<img
ref={imageRef}
src={src}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={(e) => {
console.error("Error loading image", e);
setFailedToLoadImage(true);
}}
width={size.width}
height={size.height}
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,
"blur-sm opacity-80 loading-image": !remoteImageSrc,
})}
style={{
width: size.width,
height: size.height,
}}
/>
<ImageToolbarRoot
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"
image={{
src,
height: size.height,
width: size.width,
aspectRatio: size.aspectRatio,
}}
/>
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
{showImageUtils && (
<ImageToolbarRoot
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"
}
image={{
src: remoteImageSrc,
aspectRatio: size.aspectRatio,
height: size.height,
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
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}
/>
</>
Expand Down
Loading