[WIKI-449] feat: image block download and alignment options (#7254)

* refactor: custom image extension

* refactor: extension config

* revert: image full screen component

* fix: undo operation

* chore: add download and alignment options

* chore: render image full screen modal in a portal

* chore: add missing attribute to image extension

* chore: minor bugs and improvements

* chore: add aria attributes

* chore: remove unnecessary file

* fix: full screen modal z-index
This commit is contained in:
Aaryan Khandelwal
2025-07-02 15:36:06 +05:30
committed by GitHub
parent ba6b822f60
commit f679628365
17 changed files with 456 additions and 210 deletions

View File

@@ -39,6 +39,7 @@
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.3",
"@hocuspocus/provider": "^2.15.0", "@hocuspocus/provider": "^2.15.0",
"@plane/constants": "*", "@plane/constants": "*",
"@plane/hooks": "*",
"@plane/types": "*", "@plane/types": "*",
"@plane/ui": "*", "@plane/ui": "*",
"@plane/utils": "*", "@plane/utils": "*",

View File

@@ -17,6 +17,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
setEditorContainer: (editorContainer: HTMLDivElement | null) => void; setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void; setFailedToLoadImage: (isError: boolean) => void;
src: string | undefined; src: string | undefined;
downloadSrc: string | undefined;
}; };
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => { export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
@@ -32,9 +33,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
setEditorContainer, setEditorContainer,
setFailedToLoadImage, setFailedToLoadImage,
src: resolvedImageSrc, src: resolvedImageSrc,
downloadSrc: resolvedDownloadSrc,
updateAttributes, updateAttributes,
} = props; } = 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 // states
const [size, setSize] = useState<TCustomImageSize>({ const [size, setSize] = useState<TCustomImageSize>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%", width: ensurePixelString(nodeWidth, "35%") ?? "35%",
@@ -131,12 +139,17 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
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); if (nodeAlignment === "right") {
const newHeight = newWidth / size.aspectRatio; const newWidth = Math.max(containerRect.current.right - clientX, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` })); 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(() => { const handleResizeEnd = useCallback(() => {
@@ -188,7 +201,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the image upload status only when the resolvedImageSrc is not ready // show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc; 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) // 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) // 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; const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set // show the preview image from the file system if the remote image's src is not set
@@ -197,108 +210,117 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
return ( return (
<div <div
id={getImageBlockId(node.attrs.id ?? "")} id={getImageBlockId(node.attrs.id ?? "")}
ref={containerRef} className={cn("w-fit max-w-full transition-all", {
className="group/image-component relative inline-block max-w-full" "ml-[50%] -translate-x-1/2": nodeAlignment === "center",
onMouseDown={handleImageMouseDown} "ml-[100%] -translate-x-full": nodeAlignment === "right",
style={{ })}
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
> >
{showImageLoader && ( <div
<div ref={containerRef}
className="animate-pulse bg-custom-background-80 rounded-md" className="group/image-component relative inline-block max-w-full"
style={{ width: size.width, height: size.height }} onMouseDown={handleImageMouseDown}
/>
)}
<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,
})}
style={{ style={{
width: size.width, width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }), ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}} }}
/> >
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />} {showImageLoader && (
{showImageUtils && ( <div
<ImageToolbarRoot className="animate-pulse bg-custom-background-80 rounded-md"
containerClassName={ style={{ width: size.width, height: size.height }}
"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={{ <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,
})}
style={{
width: size.width, width: size.width,
height: size.height, ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
}} }}
/> />
)} {showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
{selected && displayedImageSrc === resolvedImageSrc && ( {showImageToolbar && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" /> <ImageToolbarRoot
)} alignment={nodeAlignment ?? "left"}
{showImageResizer && ( width={size.width}
<> height={size.height}
<div aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
className={cn( src={resolvedImageSrc}
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out", downloadSrc={resolvedDownloadSrc}
{ handleAlignmentChange={(alignment) =>
"opacity-100": isResizing, updateAttributesSafely({ alignment }, "Failed to update attributes while changing alignment:")
"opacity-0 group-hover/image-component:opacity-100": !isResizing, }
}
)}
/> />
<div )}
className={cn( {selected && displayedImageSrc === resolvedImageSrc && (
"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", <div className="absolute inset-0 size-full bg-custom-primary-500/30 pointer-events-none" />
{ )}
"opacity-100 pointer-events-auto": isResizing, {showImageResizer && (
"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto": <>
!isResizing, <div
} className={cn(
)} "absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
onMouseDown={handleResizeStart} {
onTouchStart={handleResizeStart} "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> </div>
); );
}; };

View File

@@ -26,6 +26,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
const [isUploaded, setIsUploaded] = useState(false); const [isUploaded, setIsUploaded] = useState(false);
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined); const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined); const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadImage, setFailedToLoadImage] = useState(false); const [failedToLoadImage, setFailedToLoadImage] = useState(false);
@@ -53,12 +54,15 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
useEffect(() => { useEffect(() => {
if (!imgNodeSrc) { if (!imgNodeSrc) {
setResolvedSrc(undefined); setResolvedSrc(undefined);
setResolvedDownloadSrc(undefined);
return; return;
} }
const getImageSource = async () => { const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc); const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url); setResolvedSrc(url);
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
setResolvedDownloadSrc(downloadUrl);
}; };
getImageSource(); getImageSource();
}, [imgNodeSrc, extension.options]); }, [imgNodeSrc, extension.options]);
@@ -73,6 +77,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
setEditorContainer={setEditorContainer} setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage} setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc} src={resolvedSrc}
downloadSrc={resolvedDownloadSrc}
{...props} {...props}
/> />
) : ( ) : (

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -1,37 +1,39 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; import { Download, ExternalLink, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
// plane imports // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
type Props = {
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
};
const MIN_ZOOM = 0.5; const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2; const MAX_ZOOM = 2;
const ZOOM_SPEED = 0.05; const ZOOM_SPEED = 0.05;
const ZOOM_STEPS = [0.5, 1, 1.5, 2]; const ZOOM_STEPS = [0.5, 1, 1.5, 2];
export const ImageFullScreenAction: React.FC<Props> = (props) => { type Props = {
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props; aspectRatio: number;
const { src, width, aspectRatio } = image; isFullScreenEnabled: boolean;
downloadSrc: string;
src: string;
toggleFullScreenMode: (val: boolean) => void;
width: string;
};
const ImageFullScreenModalWithoutPortal = (props: Props) => {
const { aspectRatio, isFullScreenEnabled, downloadSrc, src, toggleFullScreenMode, width } = props;
// refs
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
const [magnification, setMagnification] = useState<number>(1); const [magnification, setMagnification] = useState<number>(1);
const [initialMagnification, setInitialMagnification] = useState(1); const [initialMagnification, setInitialMagnification] = useState(1);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]); const widthInNumber = useMemo(() => {
if (!width) return 0;
return Number(width.replace("px", ""));
}, [width]);
const setImageRef = useCallback( const setImageRef = useCallback(
(node: HTMLImageElement | null) => { (node: HTMLImageElement | null) => {
@@ -148,7 +150,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
e.preventDefault(); e.preventDefault();
// Handle pinch-to-zoom // Handle pinch-to-zoom
if (e.ctrlKey) { if (e.ctrlKey || e.metaKey) {
const delta = e.deltaY; const delta = e.deltaY;
setMagnification((prev) => { setMagnification((prev) => {
const newZoom = prev * (1 - delta * ZOOM_SPEED); const newZoom = prev * (1 - delta * ZOOM_SPEED);
@@ -165,7 +167,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
return; return;
} }
}, },
[isFullScreenEnabled, magnification] [isFullScreenEnabled]
); );
// Event listeners // Event listeners
@@ -185,84 +187,99 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
}; };
}, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]); }, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);
if (!isFullScreenEnabled) return null;
return ( return (
<> <div
className={cn("fixed inset-0 size-full z-30 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
role="dialog"
aria-modal="true"
aria-label="Fullscreen image viewer"
>
<div <div
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", { ref={modalRef}
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled, onMouseDown={(e) => e.target === modalRef.current && handleClose()}
"cursor-default": !isDragging, className="relative size-full grid place-items-center overflow-hidden"
"cursor-grabbing": isDragging,
})}
> >
<div <button
ref={modalRef} type="button"
onMouseDown={(e) => e.target === modalRef.current && handleClose()} onClick={handleClose}
className="relative size-full grid place-items-center overflow-hidden" className="absolute top-10 right-10 size-8 grid place-items-center"
aria-label="Close image viewer"
> >
<button <X className="size-8 text-white/60 hover:text-white transition-colors" />
type="button" </button>
onClick={handleClose} <img
className="absolute top-10 right-10 size-8 grid place-items-center" ref={setImageRef}
> src={src}
<X className="size-8 text-white/60 hover:text-white transition-colors" /> className="read-only-image rounded-lg"
</button> style={{
<img width: `${widthInNumber * initialMagnification}px`,
ref={setImageRef} maxWidth: "none",
src={src} maxHeight: "none",
className="read-only-image rounded-lg" aspectRatio,
style={{ position: "relative",
width: `${widthInNumber * initialMagnification}px`, transform: `scale(${magnification})`,
maxWidth: "none", transformOrigin: "center",
maxHeight: "none", transition: "width 0.2s ease, transform 0.2s ease",
aspectRatio, }}
position: "relative", onMouseDown={handleMouseDown}
transform: `scale(${magnification})`, />
transformOrigin: "center", <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">
transition: "width 0.2s ease, transform 0.2s ease", <div className="flex items-center">
}}
onMouseDown={handleMouseDown}
/>
<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">
<button
type="button"
onClick={() => handleMagnification("decrease")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification <= MIN_ZOOM}
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={() => handleMagnification("increase")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification >= MAX_ZOOM}
>
<Plus className="size-4" />
</button>
</div>
<button <button
type="button" type="button"
onClick={() => window.open(src, "_blank")} onClick={() => handleMagnification("decrease")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200" className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification <= MIN_ZOOM}
aria-label="Zoom out"
> >
<ExternalLink className="size-4" /> <Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={() => handleMagnification("increase")}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification >= MAX_ZOOM}
aria-label="Zoom in"
>
<Plus className="size-4" />
</button> </button>
</div> </div>
<button
type="button"
onClick={() => window.open(downloadSrc, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Download image"
>
<Download className="size-4" />
</button>
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Open image in new tab"
>
<ExternalLink className="size-4" />
</button>
</div> </div>
</div> </div>
<button </div>
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFullScreenMode(true);
}}
className="size-5 grid place-items-center hover:bg-black/40 text-white rounded transition-colors"
>
<Maximize className="size-3" />
</button>
</>
); );
}; };
export const ImageFullScreenModal: React.FC<Props> = (props) => {
let modal = <ImageFullScreenModalWithoutPortal {...props} />;
const portal = document.querySelector("#editor-portal");
if (portal) {
modal = ReactDOM.createPortal(modal, portal);
} else {
console.warn("Portal element #editor-portal not found. Rendering inline.");
}
return modal;
};

View File

@@ -0,0 +1,56 @@
import { Maximize } from "lucide-react";
import { useEffect, useState } from "react";
// plane imports
import { Tooltip } from "@plane/ui";
// local imports
import { ImageFullScreenModal } from "./modal";
type Props = {
image: {
downloadSrc: string;
src: string;
height: string;
width: string;
aspectRatio: number;
};
toggleToolbarViewStatus: (val: boolean) => void;
};
export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {
const { image, toggleToolbarViewStatus } = props;
// states
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
// derived values
const { downloadSrc, src, width, aspectRatio } = image;
useEffect(() => {
toggleToolbarViewStatus(isFullScreenEnabled);
}, [isFullScreenEnabled, toggleToolbarViewStatus]);
return (
<>
<ImageFullScreenModal
aspectRatio={aspectRatio}
isFullScreenEnabled={isFullScreenEnabled}
src={src}
downloadSrc={downloadSrc}
width={width}
toggleFullScreenMode={setIsFullScreenEnabled}
/>
<Tooltip tooltipContent="View in full screen">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsFullScreenEnabled(true);
}}
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
aria-label="View image in full screen"
>
<Maximize className="size-3" />
</button>
</Tooltip>
</>
);
};

View File

@@ -2,35 +2,43 @@ import { useState } from "react";
// plane imports // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// local imports // local imports
import { ImageFullScreenAction } from "./full-screen"; import type { TCustomImageAlignment } from "../../types";
import { ImageAlignmentAction } from "./alignment";
import { ImageDownloadAction } from "./download";
import { ImageFullScreenActionRoot } from "./full-screen";
type Props = { type Props = {
containerClassName?: string; alignment: TCustomImageAlignment;
image: { width: string;
width: string; height: string;
height: string; aspectRatio: number;
aspectRatio: number; src: string;
src: string; downloadSrc: string;
}; handleAlignmentChange: (alignment: TCustomImageAlignment) => void;
}; };
export const ImageToolbarRoot: React.FC<Props> = (props) => { export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { containerClassName, image } = props; const { alignment, downloadSrc, handleAlignmentChange } = props;
// state // states
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false); const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
return ( return (
<> <>
<div <div
className={cn(containerClassName, { className={cn(
"opacity-100 pointer-events-auto": isFullScreenEnabled, "absolute top-1 right-1 h-7 z-20 bg-black/80 rounded flex items-center gap-2 px-2 opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity",
})} {
"opacity-100 pointer-events-auto": shouldShowToolbar,
}
)}
> >
<ImageFullScreenAction <ImageDownloadAction src={downloadSrc} />
image={image} <ImageAlignmentAction
isOpen={isFullScreenEnabled} activeAlignment={alignment}
toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)} handleChange={handleAlignmentChange}
toggleToolbarViewStatus={setShouldShowToolbar}
/> />
<ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />
</div> </div>
</> </>
); );

View File

@@ -20,7 +20,7 @@ type Props = {
export const CustomImageExtension = (props: Props) => { export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props; const { fileHandler, isEditable } = props;
// derived values // derived values
const { getAssetSrc, restore: restoreImageFn } = fileHandler; const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend({ return CustomImageExtensionConfig.extend({
selectable: isEditable, selectable: isEditable,
@@ -31,6 +31,7 @@ export const CustomImageExtension = (props: Props) => {
return { return {
...this.parent?.(), ...this.parent?.(),
getImageDownloadSource: getAssetDownloadSrc,
getImageSource: getAssetSrc, getImageSource: getAssetSrc,
restoreImage: restoreImageFn, restoreImage: restoreImageFn,
uploadImage: upload, uploadImage: upload,

View File

@@ -8,6 +8,7 @@ export enum ECustomImageAttributeNames {
HEIGHT = "height", HEIGHT = "height",
ASPECT_RATIO = "aspectRatio", ASPECT_RATIO = "aspectRatio",
SOURCE = "src", SOURCE = "src",
ALIGNMENT = "alignment",
} }
export type Pixel = `${number}px`; export type Pixel = `${number}px`;
@@ -20,12 +21,15 @@ export type TCustomImageSize = {
aspectRatio: number | null; aspectRatio: number | null;
}; };
export type TCustomImageAlignment = "left" | "center" | "right";
export type TCustomImageAttributes = { export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null; [ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null; [ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null; [ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null; [ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null; [ECustomImageAttributeNames.SOURCE]: string | null;
[ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment;
}; };
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
@@ -37,6 +41,7 @@ export type InsertImageComponentProps = {
}; };
export type CustomImageExtensionOptions = { export type CustomImageExtensionOptions = {
getImageDownloadSource: TFileHandler["getAssetDownloadSrc"];
getImageSource: TFileHandler["getAssetSrc"]; getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"]; restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"]; uploadImage?: TFileHandler["upload"];

View File

@@ -1,10 +1,11 @@
import type { Editor } from "@tiptap/core"; import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
// constants // constants
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers // helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports // local imports
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types"; import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = { export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null, [ECustomImageAttributeNames.SOURCE]: null,
@@ -12,6 +13,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.WIDTH]: "35%", [ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto", [ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null, [ECustomImageAttributeNames.ASPECT_RATIO]: null,
[ECustomImageAttributeNames.ALIGNMENT]: "left",
}; };
export const getImageComponentImageFileMap = (editor: Editor) => export const getImageComponentImageFileMap = (editor: Editor) =>
@@ -32,4 +34,25 @@ export const ensurePixelString = <TDefault>(
return value; return value;
}; };
export const IMAGE_ALIGNMENT_OPTIONS: {
label: string;
value: TCustomImageAlignment;
icon: LucideIcon;
}[] = [
{
label: "Left",
value: "left",
icon: AlignLeft,
},
{
label: "Center",
value: "center",
icon: AlignCenter,
},
{
label: "Right",
value: "right",
icon: AlignRight,
},
];
export const getImageBlockId = (id: string) => `editor-image-block-${id}`; export const getImageBlockId = (id: string) => `editor-image-block-${id}`;

View File

@@ -19,6 +19,9 @@ export const ImageExtensionConfig = BaseImageExtension.extend<
aspectRatio: { aspectRatio: {
default: null, default: null,
}, },
alignment: {
default: "left",
},
}; };
}, },
}); });

View File

@@ -3,6 +3,7 @@ import { TWebhookConnectionQueryParams } from "@plane/types";
export type TReadOnlyFileHandler = { export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>; checkIfAssetExists: (assetId: string) => Promise<boolean>;
getAssetDownloadSrc: (path: string) => Promise<string>;
getAssetSrc: (path: string) => Promise<string>; getAssetSrc: (path: string) => Promise<string>;
restore: (assetSrc: string) => Promise<void>; restore: (assetSrc: string) => Promise<void>;
}; };

View File

@@ -33,6 +33,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
</head> </head>
<body> <body>
<div id="editor-portal" />
<AppProvider> <AppProvider>
<>{children}</> <>{children}</>
</AppProvider> </AppProvider>

View File

@@ -80,6 +80,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head> </head>
<body> <body>
<div id="context-menu-portal" /> <div id="context-menu-portal" />
<div id="editor-portal" />
<AppProvider> <AppProvider>
<div <div
className={cn( className={cn(

View File

@@ -2,7 +2,7 @@ import { useCallback } from "react";
// plane editor // plane editor
import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor"; import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
// helpers // helpers
import { getEditorAssetSrc } from "@plane/utils"; import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils";
// hooks // hooks
import { useEditorAsset } from "@/hooks/store"; import { useEditorAsset } from "@/hooks/store";
// plane web hooks // plane web hooks
@@ -33,6 +33,20 @@ export const useEditorConfig = () => {
const res = await fileService.checkIfAssetExists(workspaceSlug, assetId); const res = await fileService.checkIfAssetExists(workspaceSlug, assetId);
return res?.exists ?? false; return res?.exists ?? false;
}, },
getAssetDownloadSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return (
getEditorAssetDownloadSrc({
assetId: path,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
getAssetSrc: async (path) => { getAssetSrc: async (path) => {
if (!path) return ""; if (!path) return "";
if (path?.startsWith("http")) { if (path?.startsWith("http")) {