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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";

Expand All @@ -14,90 +14,191 @@ type Props = {
toggleFullScreenMode: (val: boolean) => void;
};

const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
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;
// states
const [magnification, setMagnification] = useState(1);
// refs

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);
// derived values
const imgRef = useRef<HTMLImageElement>(null);

const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
// close handler

const setImageRef = useCallback(
(node: HTMLImageElement | null) => {
if (!node || !isFullScreenEnabled) return;

imgRef.current = node;

const viewportWidth = window.innerWidth * 0.9;
const viewportHeight = window.innerHeight * 0.75;
const imageWidth = widthInNumber;
const imageHeight = imageWidth / aspectRatio;

const widthRatio = viewportWidth / imageWidth;
const heightRatio = viewportHeight / imageHeight;

setInitialMagnification(Math.min(widthRatio, heightRatio));
setMagnification(1);

// Reset image position
node.style.left = "0px";
node.style.top = "0px";
},
[isFullScreenEnabled, widthInNumber, aspectRatio]
);

const handleClose = useCallback(() => {
if (isDragging) return;
toggleFullScreenMode(false);
setTimeout(() => {
setMagnification(1);
}, 200);
}, [toggleFullScreenMode]);
// download handler
const handleOpenInNewTab = () => {
const link = document.createElement("a");
link.href = src;
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// magnification decrease handler
const handleDecreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === 0) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
}, [magnification]);
// magnification increase handler
const handleIncreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
}, [magnification]);
// keydown handler
setMagnification(1);
setInitialMagnification(1);
}, [isDragging, toggleFullScreenMode]);

const handleMagnification = useCallback((direction: "increase" | "decrease") => {
setMagnification((prev) => {
// Find the appropriate target zoom level based on current magnification
let targetZoom: number;
if (direction === "increase") {
targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM;
} else {
// Reverse the array to find the next lower step
targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM;
}

// Reset position when zoom matches initial magnification
if (targetZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}

return targetZoom;
});
}, []);

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault();
e.stopPropagation();

if (e.key === "Escape") handleClose();
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
if (e.key === "-") handleDecreaseMagnification();
if (e.key === "+" || e.key === "=") handleMagnification("increase");
if (e.key === "-") handleMagnification("decrease");
}
},
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
[handleClose, handleMagnification]
);
// click outside handler
const handleClickOutside = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (modalRef.current && e.target === modalRef.current) {
handleClose();

const handleMouseDown = (e: React.MouseEvent) => {
if (!imgRef.current) return;

const imgWidth = imgRef.current.offsetWidth * magnification;
const imgHeight = imgRef.current.offsetHeight * magnification;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;

if (imgWidth > viewportWidth || imgHeight > viewportHeight) {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
dragOffset.current = {
x: parseInt(imgRef.current.style.left || "0"),
y: parseInt(imgRef.current.style.top || "0"),
};
}
};

const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !imgRef.current) return;

const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;

// Apply the scale factor to the drag movement
const scaledDx = dx / magnification;
const scaledDy = dy / magnification;

imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`;
imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`;
},
[isDragging, magnification]
);

const handleMouseUp = useCallback(() => {
if (!isDragging || !imgRef.current) return;
setIsDragging(false);
}, [isDragging]);

const handleWheel = useCallback(
(e: WheelEvent) => {
if (!imgRef.current || !isFullScreenEnabled) return;

e.preventDefault();

// Handle pinch-to-zoom
if (e.ctrlKey) {
const delta = e.deltaY;
setMagnification((prev) => {
const newZoom = prev * (1 - delta * ZOOM_SPEED);
const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM);

// Reset position when zoom matches initial magnification
if (clampedZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}

return clampedZoom;
});
return;
}
},
[handleClose]
[isFullScreenEnabled, magnification]
);
Comment on lines +144 to 169
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix handleWheel implementation issues

Two issues need attention:

  1. The wheel zoom sensitivity might be too high for precise control
  2. The dependency array is missing dependencies used in the callback
-const ZOOM_SPEED = 0.05;
+const ZOOM_SPEED = 0.01;

 const handleWheel = useCallback(
   (e: WheelEvent) => {
     // ... existing code ...
   },
-  [isFullScreenEnabled, magnification]
+  [isFullScreenEnabled]
 );

Committable suggestion skipped: line range outside the PR's diff.

// register keydown listener

// Event listeners
useEffect(() => {
if (isFullScreenEnabled) {
document.addEventListener("keydown", handleKeyDown);
if (!isFullScreenEnabled) return;

return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
}, [handleKeyDown, isFullScreenEnabled]);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("wheel", handleWheel, { passive: false });

return () => {
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("wheel", handleWheel);
};
}, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);

return (
<>
<div
className={cn(
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
{
"opacity-100 pointer-events-auto": isFullScreenEnabled,
}
)}
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
>
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
<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}
Expand All @@ -106,41 +207,49 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
ref={setImageRef}
src={src}
className="read-only-image rounded-lg transition-all duration-200"
className="read-only-image rounded-lg"
style={{
width: `${widthInNumber * magnification}px`,
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>
<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">
<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>
Comment on lines +225 to +244
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance accessibility for zoom controls

The zoom controls need proper ARIA labels and roles for better accessibility.

 <button
   type="button"
   onClick={() => handleMagnification("decrease")}
+  aria-label="Zoom out"
   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">
+<span className="text-sm w-12 text-center text-white" role="status" aria-live="polite">
   {Math.round(100 * magnification)}%
 </span>
 <button
   type="button"
   onClick={() => handleMagnification("increase")}
+  aria-label="Zoom in"
   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>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
<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")}
aria-label="Zoom out"
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" role="status" aria-live="polite">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={() => handleMagnification("increase")}
aria-label="Zoom in"
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
type="button"
onClick={handleDecreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[0]}
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"
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
<button
type="button"
onClick={handleIncreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
>
<Plus className="size-4" />
<ExternalLink className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
>
<ExternalLink className="size-4" />
</button>
</div>
</div>
<button
Expand Down