mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 11:57:56 +01:00
[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:
committed by
GitHub
parent
ba6b822f60
commit
f679628365
@@ -39,6 +39,7 @@
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@hocuspocus/provider": "^2.15.0",
|
||||
"@plane/constants": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -19,6 +19,9 @@ export const ImageExtensionConfig = BaseImageExtension.extend<
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
alignment: {
|
||||
default: "left",
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Reference in New Issue
Block a user