diff --git a/src/components/ui/FullscreenViewer.jsx b/src/components/ui/FullscreenViewer.jsx new file mode 100644 index 0000000..3d1a842 --- /dev/null +++ b/src/components/ui/FullscreenViewer.jsx @@ -0,0 +1,151 @@ +import PropTypes from "prop-types"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; + +const FullscreenViewer = ({ + images, + currentIndex, + scale, + position, + isFullscreen, + isZoomed, + canGoPrev, + canGoNext, + isDragging, + containerRef, + onClose, + onPrev, + onNext, + onWheel, + onImageClick, + onMouseDown, + onMouseMove, + onMouseUp, + onTouchStart, + onTouchMove, + onTouchEnd, +}) => { + return ( +
+ {/* Close button */} + + + {/* Previous button */} + + + {/* Next button */} + + + {/* Image */} + {isFullscreen && ( + {images[currentIndex].alt} + )} + + {/* Gesture capture layer - handles all mouse/touch interactions */} +
+ ); +}; + +FullscreenViewer.propTypes = { + images: PropTypes.arrayOf( + PropTypes.shape({ + image: PropTypes.string.isRequired, + alt: PropTypes.string, + }) + ).isRequired, + currentIndex: PropTypes.number.isRequired, + scale: PropTypes.number.isRequired, + position: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }).isRequired, + isFullscreen: PropTypes.bool.isRequired, + isZoomed: PropTypes.bool.isRequired, + canGoPrev: PropTypes.bool.isRequired, + canGoNext: PropTypes.bool.isRequired, + isDragging: PropTypes.shape({ current: PropTypes.bool }).isRequired, + containerRef: PropTypes.shape({ current: PropTypes.any }).isRequired, + onClose: PropTypes.func.isRequired, + onPrev: PropTypes.func.isRequired, + onNext: PropTypes.func.isRequired, + onWheel: PropTypes.func.isRequired, + onImageClick: PropTypes.func.isRequired, + onMouseDown: PropTypes.func.isRequired, + onMouseMove: PropTypes.func.isRequired, + onMouseUp: PropTypes.func.isRequired, + onTouchStart: PropTypes.func.isRequired, + onTouchMove: PropTypes.func.isRequired, + onTouchEnd: PropTypes.func.isRequired, +}; + +export default FullscreenViewer; diff --git a/src/components/ui/FullscreenViewer.test.jsx b/src/components/ui/FullscreenViewer.test.jsx new file mode 100644 index 0000000..e876de6 --- /dev/null +++ b/src/components/ui/FullscreenViewer.test.jsx @@ -0,0 +1,238 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import FullscreenViewer from "./FullscreenViewer"; + +const mockImages = [ + { id: 1, image: "/image1.jpg", alt: "Imagem 1" }, + { id: 2, image: "/image2.jpg", alt: "Imagem 2" }, + { id: 3, image: "/image3.jpg", alt: "Imagem 3" }, +]; + +const defaultProps = { + images: mockImages, + currentIndex: 0, + scale: 1, + position: { x: 0, y: 0 }, + isFullscreen: true, + isZoomed: false, + canGoPrev: false, + canGoNext: true, + isDragging: { current: false }, + containerRef: { current: null }, + onClose: vi.fn(), + onPrev: vi.fn(), + onNext: vi.fn(), + onWheel: vi.fn(), + onImageClick: vi.fn(), + onMouseDown: vi.fn(), + onMouseMove: vi.fn(), + onMouseUp: vi.fn(), + onTouchStart: vi.fn(), + onTouchMove: vi.fn(), + onTouchEnd: vi.fn(), +}; + +describe("FullscreenViewer", () => { + it("renders when isFullscreen is true", () => { + render(); + + expect( + screen.getByAltText("Imagem 1") + ).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + }); + + it("is hidden when isFullscreen is false", () => { + render(); + + expect( + screen.getByLabelText("Visualizador de imagens em ecrã inteiro") + ).toHaveClass("hidden"); + }); + + it("renders navigation buttons", () => { + render(); + + expect(screen.getByLabelText("Foto anterior")).toBeInTheDocument(); + expect(screen.getByLabelText("Próxima foto")).toBeInTheDocument(); + expect(screen.getByLabelText("Fechar")).toBeInTheDocument(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Fechar")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onPrev when previous button is clicked", () => { + const onPrev = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Foto anterior")); + expect(onPrev).toHaveBeenCalledTimes(1); + }); + + it("calls onNext when next button is clicked", () => { + const onNext = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Próxima foto")); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it("shows correct image based on currentIndex", () => { + render(); + + expect(screen.getByAltText("Imagem 2")).toBeInTheDocument(); + expect(screen.getByText("2 / 3")).toBeInTheDocument(); + }); + + it("applies zoom-in cursor when not zoomed", () => { + render(); + + const gestureButton = screen.getByLabelText("Clique para ampliar"); + expect(gestureButton).toHaveClass("cursor-zoom-in"); + }); + + it("applies grab cursor when zoomed", () => { + render(); + + const gestureButton = screen.getByLabelText("Arraste para mover a imagem"); + expect(gestureButton).toHaveClass("cursor-grab"); + }); + + it("calls onImageClick when gesture layer is clicked", () => { + const onImageClick = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Clique para ampliar")); + expect(onImageClick).toHaveBeenCalledTimes(1); + }); + + it("calls onWheel when scrolling on gesture layer", () => { + const onWheel = vi.fn(); + render(); + + fireEvent.wheel(screen.getByLabelText("Clique para ampliar")); + expect(onWheel).toHaveBeenCalledTimes(1); + }); + + it("calls mouse handlers on gesture layer", () => { + const onMouseDown = vi.fn(); + const onMouseMove = vi.fn(); + const onMouseUp = vi.fn(); + render( + + ); + + const gestureLayer = screen.getByLabelText("Clique para ampliar"); + fireEvent.mouseDown(gestureLayer); + fireEvent.mouseMove(gestureLayer); + fireEvent.mouseUp(gestureLayer); + + expect(onMouseDown).toHaveBeenCalledTimes(1); + expect(onMouseMove).toHaveBeenCalledTimes(1); + expect(onMouseUp).toHaveBeenCalledTimes(1); + }); + + it("calls onMouseUp on mouse leave", () => { + const onMouseUp = vi.fn(); + render(); + + const gestureLayer = screen.getByLabelText("Clique para ampliar"); + fireEvent.mouseLeave(gestureLayer); + + expect(onMouseUp).toHaveBeenCalledTimes(1); + }); + + it("calls touch handlers on gesture layer", () => { + const onTouchStart = vi.fn(); + const onTouchMove = vi.fn(); + const onTouchEnd = vi.fn(); + render( + + ); + + const gestureLayer = screen.getByLabelText("Clique para ampliar"); + fireEvent.touchStart(gestureLayer); + fireEvent.touchMove(gestureLayer); + fireEvent.touchEnd(gestureLayer); + + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(onTouchMove).toHaveBeenCalledTimes(1); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + }); + + it("applies transform style based on scale and position", () => { + render( + + ); + + const image = screen.getByAltText("Imagem 1"); + expect(image.style.transform).toBe("scale(2) translate(50px, 25px)"); + }); + + it("disables transition when dragging", () => { + render( + + ); + + const image = screen.getByAltText("Imagem 1"); + expect(image.style.transition).toBe("none"); + }); + + it("enables smooth transition when not dragging", () => { + render( + + ); + + const image = screen.getByAltText("Imagem 1"); + expect(image.style.transition).toBe("transform 0.15s ease-out"); + }); + + it("shows disabled style for prev button when canGoPrev is false", () => { + render(); + + const prevButton = screen.getByLabelText("Foto anterior"); + expect(prevButton).toHaveClass("cursor-not-allowed"); + expect(prevButton).toHaveClass("text-white/20"); + }); + + it("shows enabled style for prev button when canGoPrev is true", () => { + render(); + + const prevButton = screen.getByLabelText("Foto anterior"); + expect(prevButton).not.toHaveClass("cursor-not-allowed"); + expect(prevButton).toHaveClass("text-white/70"); + }); + + it("shows disabled style for next button when canGoNext is false", () => { + render(); + + const nextButton = screen.getByLabelText("Próxima foto"); + expect(nextButton).toHaveClass("cursor-not-allowed"); + expect(nextButton).toHaveClass("text-white/20"); + }); +}); diff --git a/src/components/ui/Lightbox.jsx b/src/components/ui/Lightbox.jsx deleted file mode 100644 index c0eb8d2..0000000 --- a/src/components/ui/Lightbox.jsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useEffect, useRef, useCallback } from "react"; -import PropTypes from "prop-types"; -import { X, ChevronLeft, ChevronRight } from "lucide-react"; - -const Lightbox = ({ images, currentIndex, onClose, onNavigate }) => { - const touchStartX = useRef(null); - const containerRef = useRef(null); - - const canGoPrev = currentIndex > 0; - const canGoNext = currentIndex < images.length - 1; - - useEffect(() => { - const enterFullscreen = async () => { - try { - if (document.fullscreenElement === null) { - await containerRef.current?.requestFullscreen(); - } - } catch { - // Fullscreen not supported or denied - continue without it - } - }; - - enterFullscreen(); - - return () => { - if (document.fullscreenElement) { - document.exitFullscreen().catch(() => {}); - } - }; - }, []); - - const navigate = useCallback( - (direction) => { - if (direction === "prev" && currentIndex > 0) { - onNavigate(currentIndex - 1); - } else if (direction === "next" && currentIndex < images.length - 1) { - onNavigate(currentIndex + 1); - } - }, - [currentIndex, images.length, onNavigate] - ); - - // Swipe handlers for mobile - const handleTouchStart = (e) => { - touchStartX.current = e.touches[0].clientX; - }; - - const handleTouchEnd = (e) => { - if (touchStartX.current === null) return; - const diff = touchStartX.current - e.changedTouches[0].clientX; - - if (Math.abs(diff) > 50) { - navigate(diff > 0 ? "next" : "prev"); - } - touchStartX.current = null; - }; - - // Keyboard controls - useEffect(() => { - const handleKeyDown = (e) => { - if (e.key === "Escape") onClose(); - else if (e.key === "ArrowLeft") navigate("prev"); - else if (e.key === "ArrowRight") navigate("next"); - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [navigate, onClose]); - - return ( -
- {/* Backdrop - semantic button element */} - - - {/* Previous button */} - - - {/* Next button */} - - - {/* Image container */} -
- { -
-
- ); -}; - -Lightbox.propTypes = { - images: PropTypes.arrayOf( - PropTypes.shape({ - image: PropTypes.string.isRequired, - alt: PropTypes.string, - }) - ).isRequired, - currentIndex: PropTypes.number.isRequired, - onClose: PropTypes.func.isRequired, - onNavigate: PropTypes.func.isRequired, -}; - -export default Lightbox; diff --git a/src/components/ui/Lightbox.test.jsx b/src/components/ui/Lightbox.test.jsx deleted file mode 100644 index bfd24b2..0000000 --- a/src/components/ui/Lightbox.test.jsx +++ /dev/null @@ -1,238 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import Lightbox from "./Lightbox"; - -const mockImages = [ - { image: "/image1.jpg", alt: "Image 1" }, - { image: "/image2.jpg", alt: "Image 2" }, - { image: "/image3.jpg", alt: "Image 3" }, -]; - -describe("Lightbox", () => { - let onCloseMock; - let onNavigateMock; - - beforeEach(() => { - onCloseMock = vi.fn(); - onNavigateMock = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const renderLightbox = (currentIndex = 0) => { - return render( - - ); - }; - - it("renders the current image", () => { - renderLightbox(0); - const img = screen.getByRole("img"); - expect(img).toHaveAttribute("src", "/image1.jpg"); - expect(img).toHaveAttribute("alt", "Image 1"); - }); - - it("renders fallback alt text when alt is not provided", () => { - const imagesWithoutAlt = [{ image: "/image1.jpg" }]; - render( - - ); - const img = screen.getByRole("img"); - expect(img).toHaveAttribute("alt", "Projeto 1 de 1"); - }); - - it("renders navigation buttons", () => { - renderLightbox(); - expect(screen.getByLabelText("Foto anterior")).toBeInTheDocument(); - expect(screen.getByLabelText("Próxima foto")).toBeInTheDocument(); - expect(screen.getByLabelText("Fechar")).toBeInTheDocument(); - }); - - it("calls onClose when close button is clicked", () => { - renderLightbox(); - fireEvent.click(screen.getByLabelText("Fechar")); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("calls onClose when backdrop is clicked", () => { - renderLightbox(); - fireEvent.click(screen.getByLabelText("Fechar lightbox")); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("navigates to previous image when prev button is clicked", () => { - renderLightbox(1); - fireEvent.click(screen.getByLabelText("Foto anterior")); - expect(onNavigateMock).toHaveBeenCalledWith(0); - }); - - it("navigates to next image when next button is clicked", () => { - renderLightbox(1); - fireEvent.click(screen.getByLabelText("Próxima foto")); - expect(onNavigateMock).toHaveBeenCalledWith(2); - }); - - it("does not navigate when at first image and prev is clicked", () => { - renderLightbox(0); - fireEvent.click(screen.getByLabelText("Foto anterior")); - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - - it("does not navigate when at last image and next is clicked", () => { - renderLightbox(2); - fireEvent.click(screen.getByLabelText("Próxima foto")); - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - - it("disables prev button at first image", () => { - renderLightbox(0); - expect(screen.getByLabelText("Foto anterior")).toBeDisabled(); - }); - - it("disables next button at last image", () => { - renderLightbox(2); - expect(screen.getByLabelText("Próxima foto")).toBeDisabled(); - }); - - it("enables both buttons in the middle", () => { - renderLightbox(1); - expect(screen.getByLabelText("Foto anterior")).not.toBeDisabled(); - expect(screen.getByLabelText("Próxima foto")).not.toBeDisabled(); - }); - - describe("keyboard navigation", () => { - it("closes lightbox on Escape key", () => { - renderLightbox(); - fireEvent.keyDown(document, { key: "Escape" }); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("navigates to previous image on ArrowLeft key", () => { - renderLightbox(1); - fireEvent.keyDown(document, { key: "ArrowLeft" }); - expect(onNavigateMock).toHaveBeenCalledWith(0); - }); - - it("navigates to next image on ArrowRight key", () => { - renderLightbox(1); - fireEvent.keyDown(document, { key: "ArrowRight" }); - expect(onNavigateMock).toHaveBeenCalledWith(2); - }); - - it("does not navigate prev on ArrowLeft at first image", () => { - renderLightbox(0); - fireEvent.keyDown(document, { key: "ArrowLeft" }); - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - - it("does not navigate next on ArrowRight at last image", () => { - renderLightbox(2); - fireEvent.keyDown(document, { key: "ArrowRight" }); - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - - it("does not respond to other keys", () => { - renderLightbox(); - fireEvent.keyDown(document, { key: "Enter" }); - expect(onCloseMock).not.toHaveBeenCalled(); - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - }); - - describe("touch/swipe navigation", () => { - it("navigates to next image on swipe left", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - fireEvent.touchStart(backdrop, { - touches: [{ clientX: 300 }], - }); - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 100 }], - }); - - expect(onNavigateMock).toHaveBeenCalledWith(2); - }); - - it("navigates to previous image on swipe right", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - fireEvent.touchStart(backdrop, { - touches: [{ clientX: 100 }], - }); - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 300 }], - }); - - expect(onNavigateMock).toHaveBeenCalledWith(0); - }); - - it("does not navigate on small swipe", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - fireEvent.touchStart(backdrop, { - touches: [{ clientX: 200 }], - }); - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 180 }], - }); - - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - - it("does not navigate when touchStart was not recorded", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - // Only fire touchEnd without touchStart - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 100 }], - }); - - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - }); - - it("stops propagation when clicking navigation buttons", () => { - renderLightbox(1); // Use middle index so both buttons are enabled - - // Click prev button - should not trigger backdrop close - fireEvent.click(screen.getByLabelText("Foto anterior")); - expect(onNavigateMock).toHaveBeenCalled(); - // onClose should not be called from the backdrop click - expect(onCloseMock).toHaveBeenCalledTimes(0); - - onNavigateMock.mockClear(); - - // Click next button - should not trigger backdrop close - fireEvent.click(screen.getByLabelText("Próxima foto")); - expect(onNavigateMock).toHaveBeenCalled(); - expect(onCloseMock).toHaveBeenCalledTimes(0); - }); - - it("cleans up keyboard event listener on unmount", () => { - const removeEventListenerSpy = vi.spyOn(document, "removeEventListener"); - const { unmount } = renderLightbox(); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - "keydown", - expect.any(Function) - ); - }); -}); diff --git a/src/hooks/useFullscreenGallery.js b/src/hooks/useFullscreenGallery.js new file mode 100644 index 0000000..54d9f89 --- /dev/null +++ b/src/hooks/useFullscreenGallery.js @@ -0,0 +1,282 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +const ZOOM_LEVELS = [1, 1.5, 2.5, 4]; + +export const useFullscreenGallery = (images, carouselApi) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const containerRef = useRef(null); + const isDragging = useRef(false); + const hasMoved = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const touchStart = useRef({ x: 0, y: 0 }); + const pinchStart = useRef({ distance: 0, scale: 1 }); + + const isZoomed = scale > 1; + const canGoPrev = currentIndex > 0; + const canGoNext = currentIndex < images.length - 1; + + // Clamp position to keep image visible + const clampPosition = useCallback((x, y, currentScale) => { + const maxOffset = ((currentScale - 1) / currentScale) * 50; + const maxX = (window.innerWidth * maxOffset) / 100; + const maxY = (window.innerHeight * maxOffset) / 100; + return { + x: Math.min(Math.max(x, -maxX), maxX), + y: Math.min(Math.max(y, -maxY), maxY), + }; + }, []); + + // Open fullscreen + const open = useCallback((index) => { + setCurrentIndex(index); + setScale(1); + setPosition({ x: 0, y: 0 }); + + const container = containerRef.current; + if (container) { + if (container.requestFullscreen) { + container + .requestFullscreen() + .then(() => setIsFullscreen(true)) + .catch(() => {}); + } else if (container.webkitRequestFullscreen) { + container.webkitRequestFullscreen(); + setIsFullscreen(true); + } + } + }, []); + + // Close fullscreen + const close = useCallback(() => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + }, []); + + // Navigate with zoom reset + const goToPrev = useCallback(() => { + if (canGoPrev) { + setCurrentIndex((i) => i - 1); + setScale(1); + setPosition({ x: 0, y: 0 }); + } + }, [canGoPrev]); + + const goToNext = useCallback(() => { + if (canGoNext) { + setCurrentIndex((i) => i + 1); + setScale(1); + setPosition({ x: 0, y: 0 }); + } + }, [canGoNext]); + + // Handle fullscreen change - sync carousel when exiting + useEffect(() => { + const handleChange = () => { + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + setIsFullscreen(false); + setScale(1); + setPosition({ x: 0, y: 0 }); + carouselApi?.scrollTo(currentIndex, true); + } + }; + document.addEventListener("fullscreenchange", handleChange); + document.addEventListener("webkitfullscreenchange", handleChange); + return () => { + document.removeEventListener("fullscreenchange", handleChange); + document.removeEventListener("webkitfullscreenchange", handleChange); + }; + }, [carouselApi, currentIndex]); + + // Keyboard navigation + useEffect(() => { + if (!isFullscreen) return; + + const handleKey = (e) => { + if (e.key === "ArrowLeft") goToPrev(); + else if (e.key === "ArrowRight") goToNext(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [isFullscreen, goToPrev, goToNext]); + + // Cycle to next zoom level + const cycleZoom = useCallback(() => { + const currentLevelIndex = ZOOM_LEVELS.findIndex((z) => scale <= z); + const nextIndex = + currentLevelIndex === -1 || currentLevelIndex === ZOOM_LEVELS.length - 1 + ? 0 + : currentLevelIndex + 1; + const newScale = ZOOM_LEVELS[nextIndex]; + + setScale(newScale); + if (newScale === 1) setPosition({ x: 0, y: 0 }); + }, [scale]); + + // Mouse handlers + const handleWheel = useCallback( + (e) => { + if (!isFullscreen) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.3 : 0.3; + setScale((s) => { + const newScale = Math.min(Math.max(s + delta, 1), 4); + if (newScale === 1) setPosition({ x: 0, y: 0 }); + return newScale; + }); + }, + [isFullscreen] + ); + + const handleImageClick = useCallback(() => { + if (!isFullscreen || hasMoved.current) return; + cycleZoom(); + }, [isFullscreen, cycleZoom]); + + const handleMouseDown = useCallback( + (e) => { + if (!isFullscreen || !isZoomed) return; + isDragging.current = true; + hasMoved.current = false; + dragStart.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + }, + [isFullscreen, isZoomed, position] + ); + + const handleMouseMove = useCallback( + (e) => { + if (!isDragging.current) return; + hasMoved.current = true; + const newX = e.clientX - dragStart.current.x; + const newY = e.clientY - dragStart.current.y; + setPosition(clampPosition(newX, newY, scale)); + }, + [clampPosition, scale] + ); + + const handleMouseUp = useCallback(() => { + isDragging.current = false; + }, []); + + // Touch handlers + const handleTouchStart = useCallback( + (e) => { + if (!isFullscreen) return; + if (e.touches.length === 2) { + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + pinchStart.current = { distance: dist, scale }; + } else if (e.touches.length === 1) { + touchStart.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; + hasMoved.current = false; + if (isZoomed) { + isDragging.current = true; + dragStart.current = { + x: e.touches[0].clientX - position.x, + y: e.touches[0].clientY - position.y, + }; + } + } + }, + [isFullscreen, scale, isZoomed, position] + ); + + const handleTouchMove = useCallback( + (e) => { + if (!isFullscreen) return; + if (e.touches.length === 2) { + hasMoved.current = true; + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + const newScale = Math.min( + Math.max( + pinchStart.current.scale * (dist / pinchStart.current.distance), + 1 + ), + 4 + ); + setScale(newScale); + if (newScale === 1) setPosition({ x: 0, y: 0 }); + } else if (isDragging.current && isZoomed) { + hasMoved.current = true; + const newX = e.touches[0].clientX - dragStart.current.x; + const newY = e.touches[0].clientY - dragStart.current.y; + setPosition(clampPosition(newX, newY, scale)); + } + }, + [isFullscreen, isZoomed, clampPosition, scale] + ); + + const handleTouchEnd = useCallback( + (e) => { + if (!isFullscreen) return; + + if (isDragging.current) { + isDragging.current = false; + return; + } + + // Swipe to navigate + if (!isZoomed && e.changedTouches.length === 1) { + const diff = touchStart.current.x - e.changedTouches[0].clientX; + if (Math.abs(diff) > 50) { + hasMoved.current = true; + if (diff > 0) goToNext(); + else goToPrev(); + return; + } + } + + // Tap to zoom + if (!hasMoved.current && e.changedTouches.length === 1) { + cycleZoom(); + } + }, + [isFullscreen, isZoomed, goToNext, goToPrev, cycleZoom] + ); + + return { + // State + isFullscreen, + currentIndex, + scale, + position, + isZoomed, + canGoPrev, + canGoNext, + containerRef, + isDragging, + + // Actions + open, + close, + goToPrev, + goToNext, + + // Handlers + handleWheel, + handleImageClick, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +}; diff --git a/src/hooks/useFullscreenGallery.test.js b/src/hooks/useFullscreenGallery.test.js new file mode 100644 index 0000000..92a8266 --- /dev/null +++ b/src/hooks/useFullscreenGallery.test.js @@ -0,0 +1,910 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useFullscreenGallery } from "./useFullscreenGallery"; + +const mockImages = [ + { id: 1, image: "/image1.jpg", alt: "Imagem 1" }, + { id: 2, image: "/image2.jpg", alt: "Imagem 2" }, + { id: 3, image: "/image3.jpg", alt: "Imagem 3" }, +]; + +const mockCarouselApi = { + scrollTo: vi.fn(), +}; + +// Helper to setup fullscreen state +const setupFullscreen = async (result, index = 0) => { + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(index); + }); + + // Simulate fullscreen being active + Object.defineProperty(document, "fullscreenElement", { + value: mockContainer, + writable: true, + configurable: true, + }); + + await act(async () => { + document.dispatchEvent(new Event("fullscreenchange")); + }); + + return mockContainer; +}; + +describe("useFullscreenGallery", () => { + let originalRequestFullscreen; + let originalExitFullscreen; + let originalFullscreenElement; + + beforeEach(() => { + originalRequestFullscreen = Element.prototype.requestFullscreen; + originalExitFullscreen = document.exitFullscreen; + originalFullscreenElement = Object.getOwnPropertyDescriptor(document, "fullscreenElement"); + + Element.prototype.requestFullscreen = vi.fn().mockResolvedValue(); + document.exitFullscreen = vi.fn().mockResolvedValue(); + + // Reset fullscreenElement + Object.defineProperty(document, "fullscreenElement", { + value: null, + writable: true, + configurable: true, + }); + + vi.clearAllMocks(); + }); + + afterEach(() => { + Element.prototype.requestFullscreen = originalRequestFullscreen; + document.exitFullscreen = originalExitFullscreen; + if (originalFullscreenElement) { + Object.defineProperty(document, "fullscreenElement", originalFullscreenElement); + } + }); + + it("initializes with default state", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + expect(result.current.isFullscreen).toBe(false); + expect(result.current.currentIndex).toBe(0); + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + expect(result.current.isZoomed).toBe(false); + expect(result.current.canGoPrev).toBe(false); + expect(result.current.canGoNext).toBe(true); + }); + + it("opens fullscreen at specified index", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(1); + }); + + expect(result.current.currentIndex).toBe(1); + expect(mockContainer.requestFullscreen).toHaveBeenCalled(); + }); + + it("closes fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.close(); + }); + + expect(document.exitFullscreen).toHaveBeenCalled(); + }); + + it("navigates to previous image", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(1); + }); + + act(() => { + result.current.goToPrev(); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it("navigates to next image", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.goToNext(); + }); + + expect(result.current.currentIndex).toBe(1); + }); + + it("does not navigate past first image", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(0); + }); + + act(() => { + result.current.goToPrev(); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it("does not navigate past last image", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(2); + }); + + act(() => { + result.current.goToNext(); + }); + + expect(result.current.currentIndex).toBe(2); + }); + + it("returns correct canGoPrev and canGoNext values", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + expect(result.current.canGoPrev).toBe(false); + expect(result.current.canGoNext).toBe(true); + + await act(async () => { + result.current.open(1); + }); + + expect(result.current.canGoPrev).toBe(true); + expect(result.current.canGoNext).toBe(true); + + await act(async () => { + result.current.open(2); + }); + + expect(result.current.canGoPrev).toBe(true); + expect(result.current.canGoNext).toBe(false); + }); + + it("resets scale and position when opening", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(0); + }); + + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("handles wheel zoom in fullscreen", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + const mockEvent = { + deltaY: -100, + preventDefault: vi.fn(), + }; + + act(() => { + result.current.handleWheel(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(result.current.scale).toBeGreaterThan(1); + }); + + it("zooms out with wheel when deltaY is positive", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // First zoom in + act(() => { + result.current.handleWheel({ deltaY: -100, preventDefault: vi.fn() }); + result.current.handleWheel({ deltaY: -100, preventDefault: vi.fn() }); + }); + + const zoomedScale = result.current.scale; + + // Then zoom out + act(() => { + result.current.handleWheel({ deltaY: 100, preventDefault: vi.fn() }); + }); + + expect(result.current.scale).toBeLessThan(zoomedScale); + }); + + it("clamps zoom to minimum of 1", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Try to zoom out below 1 + act(() => { + result.current.handleWheel({ deltaY: 100, preventDefault: vi.fn() }); + result.current.handleWheel({ deltaY: 100, preventDefault: vi.fn() }); + result.current.handleWheel({ deltaY: 100, preventDefault: vi.fn() }); + }); + + expect(result.current.scale).toBe(1); + }); + + it("clamps zoom to maximum of 4", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in many times + for (let i = 0; i < 20; i++) { + act(() => { + result.current.handleWheel({ deltaY: -100, preventDefault: vi.fn() }); + }); + } + + expect(result.current.scale).toBeLessThanOrEqual(4); + }); + + it("cycles through zoom levels on image click", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + expect(result.current.scale).toBe(1); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(1.5); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(2.5); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(4); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles mouse drag when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in first + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.isZoomed).toBe(true); + + // Start drag + act(() => { + result.current.handleMouseDown({ clientX: 100, clientY: 100 }); + }); + + expect(result.current.isDragging.current).toBe(true); + + // Move + act(() => { + result.current.handleMouseMove({ clientX: 150, clientY: 150 }); + }); + + expect(result.current.position.x).not.toBe(0); + expect(result.current.position.y).not.toBe(0); + + // End drag + act(() => { + result.current.handleMouseUp(); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("does not click zoom if dragged", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleImageClick(); + }); + + const initialScale = result.current.scale; + + // Simulate drag (mouseDown, mouseMove sets hasMoved) + act(() => { + result.current.handleMouseDown({ clientX: 100, clientY: 100 }); + result.current.handleMouseMove({ clientX: 200, clientY: 200 }); + result.current.handleMouseUp(); + }); + + // Click should not change zoom because hasMoved is true + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(initialScale); + }); + + it("handles touch start for drag when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in first + act(() => { + result.current.handleImageClick(); + }); + + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + expect(result.current.isDragging.current).toBe(true); + }); + + it("handles touch move for drag when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleImageClick(); + }); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // Move touch + act(() => { + result.current.handleTouchMove({ + touches: [{ clientX: 150, clientY: 150 }], + }); + }); + + expect(result.current.position.x).not.toBe(0); + }); + + it("handles pinch zoom with two fingers", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Start pinch + act(() => { + result.current.handleTouchStart({ + touches: [ + { clientX: 100, clientY: 100 }, + { clientX: 200, clientY: 200 }, + ], + }); + }); + + // Spread fingers (pinch out = zoom in) + act(() => { + result.current.handleTouchMove({ + touches: [ + { clientX: 50, clientY: 50 }, + { clientX: 250, clientY: 250 }, + ], + }); + }); + + expect(result.current.scale).toBeGreaterThan(1); + }); + + it("handles swipe to navigate when not zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + expect(result.current.currentIndex).toBe(1); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 200, clientY: 100 }], + }); + }); + + // Swipe left (next) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 100 }], + }); + }); + + expect(result.current.currentIndex).toBe(2); + }); + + it("handles swipe right to go previous", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // Swipe right (previous) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 200 }], + }); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it("handles tap to zoom on touch", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // End touch at same position (tap) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 100 }], + }); + }); + + expect(result.current.scale).toBe(1.5); + }); + + it("syncs carousel when exiting fullscreen", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 2); + + // Exit fullscreen + Object.defineProperty(document, "fullscreenElement", { + value: null, + writable: true, + configurable: true, + }); + + await act(async () => { + document.dispatchEvent(new Event("fullscreenchange")); + }); + + expect(mockCarouselApi.scrollTo).toHaveBeenCalledWith(2, true); + expect(result.current.isFullscreen).toBe(false); + }); + + it("handles keyboard navigation in fullscreen", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + // Press ArrowRight + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); + }); + + expect(result.current.currentIndex).toBe(2); + + // Press ArrowLeft + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft" })); + }); + + expect(result.current.currentIndex).toBe(1); + }); + + it("handles mouse down correctly when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + clientX: 100, + clientY: 100, + }; + + act(() => { + result.current.handleMouseDown(mockEvent); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles mouse up correctly", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.handleMouseUp(); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles image click when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles wheel event when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + deltaY: -100, + preventDefault: vi.fn(), + }; + + act(() => { + result.current.handleWheel(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles touch start with single touch", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + touches: [{ clientX: 100, clientY: 100 }], + }; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles touch start with two touches for pinch", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + touches: [ + { clientX: 100, clientY: 100 }, + { clientX: 200, clientY: 200 }, + ], + }; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles touch move when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + touches: [{ clientX: 150, clientY: 150 }], + }; + + act(() => { + result.current.handleTouchMove(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles touch end when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + changedTouches: [{ clientX: 150 }], + }; + + act(() => { + result.current.handleTouchEnd(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("exposes containerRef", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + expect(result.current.containerRef).toBeDefined(); + expect(result.current.containerRef.current).toBe(null); + }); + + it("exposes isDragging ref", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + expect(result.current.isDragging).toBeDefined(); + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles mouse move when not dragging", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + clientX: 200, + clientY: 200, + }; + + act(() => { + result.current.handleMouseMove(mockEvent); + }); + + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("supports webkit fullscreen API", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = undefined; + mockContainer.webkitRequestFullscreen = vi.fn(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(0); + }); + + expect(mockContainer.webkitRequestFullscreen).toHaveBeenCalled(); + }); + + it("supports webkit exit fullscreen API", () => { + document.exitFullscreen = undefined; + document.webkitExitFullscreen = vi.fn(); + + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.close(); + }); + + expect(document.webkitExitFullscreen).toHaveBeenCalled(); + }); + + it("resets position when zooming back to 1x via wheel", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleWheel({ deltaY: -100, preventDefault: vi.fn() }); + }); + + // Drag to change position + act(() => { + result.current.handleMouseDown({ clientX: 100, clientY: 100 }); + result.current.handleMouseMove({ clientX: 150, clientY: 150 }); + result.current.handleMouseUp(); + }); + + // Zoom back to 1 + for (let i = 0; i < 10; i++) { + act(() => { + result.current.handleWheel({ deltaY: 100, preventDefault: vi.fn() }); + }); + } + + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("resets position when cycling back to 1x zoom", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Cycle through all zoom levels back to 1 + act(() => { + result.current.handleImageClick(); // 1 -> 1.5 + }); + + act(() => { + result.current.handleImageClick(); // 1.5 -> 2.5 + }); + + act(() => { + result.current.handleImageClick(); // 2.5 -> 4 + }); + + act(() => { + result.current.handleImageClick(); // 4 -> 1 + }); + + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("does not navigate on small swipes", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // Small swipe (less than 50px threshold) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 120 }], + }); + }); + + // Should stay on same image but zoom (tap) + expect(result.current.currentIndex).toBe(1); + }); + + it("handles touch end when dragging returns early", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleImageClick(); + }); + + // Start drag + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + const initialScale = result.current.scale; + + // End while still dragging + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 100 }], + }); + }); + + // Should not cycle zoom + expect(result.current.scale).toBe(initialScale); + }); +}); diff --git a/src/pages/PortfolioPage.jsx b/src/pages/PortfolioPage.jsx index 2803724..79c8e8a 100644 --- a/src/pages/PortfolioPage.jsx +++ b/src/pages/PortfolioPage.jsx @@ -7,40 +7,38 @@ import { CarouselNext, } from "@/components/ui/Carousel"; import { CTASection } from "@/components/common/CTASection"; -import Lightbox from "@/components/ui/Lightbox"; import SocialLinks from "@/components/common/SocialLinks"; import { Seo } from "@/components/common/Seo"; import { portfolioImages } from "@/data/portfolioImages"; +import { useFullscreenGallery } from "@/hooks/useFullscreenGallery"; +import FullscreenViewer from "@/components/ui/FullscreenViewer"; const PortfolioPage = () => { const [api, setApi] = useState(null); const [current, setCurrent] = useState(0); - const [lightboxIndex, setLightboxIndex] = useState(null); - const isLightboxOpen = lightboxIndex !== null; + const gallery = useFullscreenGallery(portfolioImages, api); - // Keyboard controls for carousel + // Carousel keyboard controls (when not fullscreen) useEffect(() => { - if (isLightboxOpen || !api) return; + if (gallery.isFullscreen || !api) return; const handleKeyDown = (e) => { if (e.key === "ArrowLeft") api.scrollPrev(); else if (e.key === "ArrowRight") api.scrollNext(); - else if (e.key === "Enter") setLightboxIndex(current); + else if (e.key === "Enter") gallery.open(current); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isLightboxOpen, api, current]); + }, [gallery.isFullscreen, api, current, gallery]); // Sync carousel state useEffect(() => { if (!api) return; - const onSelect = () => setCurrent(api.selectedScrollSnap()); onSelect(); api.on("select", onSelect); - return () => api.off("select", onSelect); }, [api]); @@ -68,7 +66,7 @@ const PortfolioPage = () => {