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}]({images[currentIndex].image})
+ )}
+
+ {/* Gesture capture layer - handles all mouse/touch interactions */}
+
+
+ {/* Image counter */}
+
+ {currentIndex + 1} / {images.length}
+
+
+ );
+};
+
+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 */}
-
-
- {/* Close button */}
-
-
- {/* 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 = () => {