[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",
"@hocuspocus/provider": "^2.15.0",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",

View File

@@ -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) => {
@@ -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%",
@@ -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(() => {
@@ -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
@@ -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={{
>
{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,
})}
style={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
)}
{selected && displayedImageSrc === resolvedImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{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,
}
)}
{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:")
}
/>
<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}
onTouchStart={handleResizeStart}
/>
</>
)}
)}
{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>
);
};

View File

@@ -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);
@@ -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]);
@@ -73,6 +77,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
downloadSrc={resolvedDownloadSrc}
{...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 { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Download, ExternalLink, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
// plane imports
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 MAX_ZOOM = 2;
const ZOOM_SPEED = 0.05;
const ZOOM_STEPS = [0.5, 1, 1.5, 2];
export const ImageFullScreenAction: React.FC<Props> = (props) => {
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
const { src, width, aspectRatio } = image;
type Props = {
aspectRatio: number;
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 [initialMagnification, setInitialMagnification] = useState(1);
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 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(
(node: HTMLImageElement | null) => {
@@ -148,7 +150,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
e.preventDefault();
// Handle pinch-to-zoom
if (e.ctrlKey) {
if (e.ctrlKey || e.metaKey) {
const delta = e.deltaY;
setMagnification((prev) => {
const newZoom = prev * (1 - delta * ZOOM_SPEED);
@@ -165,7 +167,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
return;
}
},
[isFullScreenEnabled, magnification]
[isFullScreenEnabled]
);
// Event listeners
@@ -185,84 +187,99 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
};
}, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);
if (!isFullScreenEnabled) return null;
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
className={cn("fixed inset-0 size-full z-20 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,
})}
ref={modalRef}
onMouseDown={(e) => e.target === modalRef.current && handleClose()}
className="relative size-full grid place-items-center overflow-hidden"
>
<div
ref={modalRef}
onMouseDown={(e) => e.target === modalRef.current && handleClose()}
className="relative size-full grid place-items-center overflow-hidden"
<button
type="button"
onClick={handleClose}
className="absolute top-10 right-10 size-8 grid place-items-center"
aria-label="Close image viewer"
>
<button
type="button"
onClick={handleClose}
className="absolute top-10 right-10 size-8 grid place-items-center"
>
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
ref={setImageRef}
src={src}
className="read-only-image rounded-lg"
style={{
width: `${widthInNumber * initialMagnification}px`,
maxWidth: "none",
maxHeight: "none",
aspectRatio,
position: "relative",
transform: `scale(${magnification})`,
transformOrigin: "center",
transition: "width 0.2s ease, transform 0.2s ease",
}}
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>
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
ref={setImageRef}
src={src}
className="read-only-image rounded-lg"
style={{
width: `${widthInNumber * initialMagnification}px`,
maxWidth: "none",
maxHeight: "none",
aspectRatio,
position: "relative",
transform: `scale(${magnification})`,
transformOrigin: "center",
transition: "width 0.2s ease, transform 0.2s ease",
}}
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={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
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}
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>
</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>
<button
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>
</>
</div>
);
};
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
import { cn } from "@plane/utils";
// 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 = {
containerClassName?: string;
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
};
alignment: TCustomImageAlignment;
width: string;
height: string;
aspectRatio: number;
src: string;
downloadSrc: string;
handleAlignmentChange: (alignment: TCustomImageAlignment) => void;
};
export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { containerClassName, image } = props;
// state
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
const { alignment, downloadSrc, handleAlignmentChange } = props;
// states
const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
return (
<>
<div
className={cn(containerClassName, {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
})}
className={cn(
"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
image={image}
isOpen={isFullScreenEnabled}
toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)}
<ImageDownloadAction src={downloadSrc} />
<ImageAlignmentAction
activeAlignment={alignment}
handleChange={handleAlignmentChange}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
<ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />
</div>
</>
);

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// 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 = {
[ECustomImageAttributeNames.SOURCE]: null,
@@ -12,6 +13,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
[ECustomImageAttributeNames.ALIGNMENT]: "left",
};
export const getImageComponentImageFileMap = (editor: Editor) =>
@@ -32,4 +34,25 @@ export const ensurePixelString = <TDefault>(
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}`;

View File

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

View File

@@ -3,6 +3,7 @@ import { TWebhookConnectionQueryParams } from "@plane/types";
export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>;
getAssetDownloadSrc: (path: string) => Promise<string>;
getAssetSrc: (path: string) => Promise<string>;
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" />
</head>
<body>
<div id="editor-portal" />
<AppProvider>
<>{children}</>
</AppProvider>

View File

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

View File

@@ -2,7 +2,7 @@ import { useCallback } from "react";
// plane editor
import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
// helpers
import { getEditorAssetSrc } from "@plane/utils";
import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils";
// hooks
import { useEditorAsset } from "@/hooks/store";
// plane web hooks
@@ -33,6 +33,20 @@ export const useEditorConfig = () => {
const res = await fileService.checkIfAssetExists(workspaceSlug, assetId);
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) => {
if (!path) return "";
if (path?.startsWith("http")) {