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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@headlessui/react": "^1.7.3",
"@hocuspocus/provider": "^2.15.0",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
Expand Down
222 changes: 122 additions & 100 deletions packages/editor/src/core/extensions/custom-image/components/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
src: string | undefined;
downloadSrc: string | undefined;
};

export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
Expand All @@ -32,9 +33,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
downloadSrc: resolvedDownloadSrc,
updateAttributes,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
const {
width: nodeWidth,
height: nodeHeight,
aspectRatio: nodeAspectRatio,
src: imgNodeSrc,
alignment: nodeAlignment,
} = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
Expand Down Expand Up @@ -131,12 +139,17 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {

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` }));
if (nodeAlignment === "right") {
const newWidth = Math.max(containerRect.current.right - clientX, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
} else {
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.aspectRatio]
[nodeAlignment, size.aspectRatio]
);

const handleResizeEnd = useCallback(() => {
Expand Down Expand Up @@ -188,7 +201,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = resolvedImageSrc && initialResizeComplete;
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
Expand All @@ -197,108 +210,117 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
return (
<div
id={getImageBlockId(node.attrs.id ?? "")}
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
className={cn("w-fit max-w-full transition-all", {
"ml-[50%] -translate-x-1/2": nodeAlignment === "center",
"ml-[100%] -translate-x-full": nodeAlignment === "right",
})}
>
{showImageLoader && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
)}
<img
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}

try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
imageRef.current.src = resolvedImageSrc;
} catch {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", e);
} finally {
setHasErroredOnFirstLoad(false);
setHasTriedRestoringImageOnce(true);
}
}}
width={size.width}
className={cn("image-component block rounded-md", {
// 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": !resolvedImageSrc,
})}
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
{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={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
}}
/>
)}
{selected && displayedImageSrc === resolvedImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{showImageResizer && (
<>
>
{showImageLoader && (
<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,
}
)}
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
<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,
)}
<img
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}

try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
imageRef.current.src = resolvedImageSrc;
} catch {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", e);
} finally {
setHasErroredOnFirstLoad(false);
setHasTriedRestoringImageOnce(true);
}
}}
width={size.width}
className={cn("image-component block rounded-md", {
// 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": !resolvedImageSrc,
})}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
{showImageToolbar && (
<ImageToolbarRoot
alignment={nodeAlignment ?? "left"}
width={size.width}
height={size.height}
aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
src={resolvedImageSrc}
downloadSrc={resolvedDownloadSrc}
handleAlignmentChange={(alignment) =>
updateAttributesSafely({ alignment }, "Failed to update attributes while changing alignment:")
}
/>
</>
)}
)}
{selected && displayedImageSrc === resolvedImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30 pointer-events-none" />
)}
{showImageResizer && (
<>
<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 translate-y-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white 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,
"left-0 -translate-x-1/2 cursor-nesw-resize": nodeAlignment === "right",
"right-0 translate-x-1/2 cursor-nwse-resize": nodeAlignment !== "right",
}
)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
</>
)}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =

const [isUploaded, setIsUploaded] = useState(false);
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadImage, setFailedToLoadImage] = useState(false);

Expand Down Expand Up @@ -53,12 +54,15 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
setResolvedDownloadSrc(undefined);
return;
}

const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
setResolvedDownloadSrc(downloadUrl);
};
getImageSource();
}, [imgNodeSrc, extension.options]);
Expand All @@ -73,6 +77,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
downloadSrc={resolvedDownloadSrc}
{...props}
/>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { Tooltip } from "@plane/ui";
// local imports
import type { TCustomImageAlignment } from "../../types";
import { IMAGE_ALIGNMENT_OPTIONS } from "../../utils";

type Props = {
activeAlignment: TCustomImageAlignment;
handleChange: (alignment: TCustomImageAlignment) => void;
toggleToolbarViewStatus: (val: boolean) => void;
};

export const ImageAlignmentAction: React.FC<Props> = (props) => {
const { activeAlignment, handleChange, toggleToolbarViewStatus } = props;
// states
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement>(null);
// derived values
const activeAlignmentDetails = IMAGE_ALIGNMENT_OPTIONS.find((option) => option.value === activeAlignment);

useOutsideClickDetector(dropdownRef, () => setIsDropdownOpen(false));

useEffect(() => {
toggleToolbarViewStatus(isDropdownOpen);
}, [isDropdownOpen, toggleToolbarViewStatus]);

return (
<div ref={dropdownRef} className="h-full relative">
<Tooltip tooltipContent="Align">
<button
type="button"
className="h-full flex items-center gap-1 text-white/60 hover:text-white transition-colors"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{activeAlignmentDetails && <activeAlignmentDetails.icon className="flex-shrink-0 size-3" />}
<ChevronDown className="flex-shrink-0 size-2" />
</button>
</Tooltip>
{isDropdownOpen && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-0.5 h-7 bg-black/80 flex items-center gap-2 px-2 rounded">
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
<Tooltip key={option.value} tooltipContent={option.label}>
<button
type="button"
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
onClick={() => {
handleChange(option.value);
setIsDropdownOpen(false);
}}
>
<option.icon className="size-3" />
</button>
</Tooltip>
))}
</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Download } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/ui";

type Props = {
src: string;
};

export const ImageDownloadAction: React.FC<Props> = (props) => {
const { src } = props;

return (
<Tooltip tooltipContent="Download">
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
aria-label="Download image"
>
<Download className="size-3" />
</button>
</Tooltip>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./root";
Loading
Loading