From ac47cc62ee210cdb4b1297199bdbfcb9aacaa740 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 23 Dec 2024 20:03:10 +0530 Subject: [PATCH] [PE-102] fix: zooming for fullscreen images (#6266) * fix: added magnification properly and also moving around the zoomed image * fix: zoom via trackpad pinch * fix: update imports * fix: initial magnification is reset --- .../custom-image/components/image-node.tsx | 2 +- .../components/toolbar/full-screen.tsx | 279 ++++++++++++------ 2 files changed, 195 insertions(+), 86 deletions(-) diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index 58b60b306d..2bd84fcb31 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -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"; diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx index 92b5904fbf..560d95cfc4 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx @@ -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"; @@ -14,46 +14,77 @@ 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) => { const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props; const { src, width, aspectRatio } = image; - // states - const [magnification, setMagnification] = useState(1); - // refs + + const [magnification, setMagnification] = useState(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(null); - // derived values + const imgRef = useRef(null); + const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]); - // close handler - const handleClose = useCallback(() => { - toggleFullScreenMode(false); - setTimeout(() => { + + 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); - }, 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 + + // Reset image position + node.style.left = "0px"; + node.style.top = "0px"; + }, + [isFullScreenEnabled, widthInNumber, aspectRatio] + ); + + const handleClose = useCallback(() => { + if (isDragging) return; + toggleFullScreenMode(false); + 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 === "-") { @@ -61,43 +92,113 @@ export const ImageFullScreenAction: React.FC = (props) => { 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) => { - if (modalRef.current && e.target === modalRef.current) { - handleClose(); - } - }, - [handleClose] - ); - // register keydown listener - useEffect(() => { - if (isFullScreenEnabled) { - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); + 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"), }; } - }, [handleKeyDown, isFullScreenEnabled]); + }; + + 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; + } + }, + [isFullScreenEnabled, magnification] + ); + + // Event listeners + useEffect(() => { + if (!isFullScreenEnabled) return; + + 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 ( <>
-
+
e.target === modalRef.current && handleClose()} + className="relative size-full grid place-items-center overflow-hidden" + > -
-
-
+
+
+ + {Math.round(100 * magnification)}% + +
- {(100 * magnification).toFixed(0)}% -
-