Files
dataroom/components/view/viewer/pages-vertical-viewer.tsx
2026-01-30 08:43:55 +11:00

1139 lines
37 KiB
TypeScript

import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import React from "react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useSafePageViewTracker } from "@/lib/tracking/safe-page-view-tracker";
import { getTrackingOptions } from "@/lib/tracking/tracking-config";
import { WatermarkConfig } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/lib/utils/use-media-query";
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { ScreenProtector } from "../ScreenProtection";
import Nav, { TNavData } from "../nav";
import { PoweredBy } from "../powered-by";
import Question from "../question";
import Toolbar from "../toolbar";
import { SVGWatermark } from "../watermark-svg";
import { AwayPoster } from "./away-poster";
import "@/styles/custom-viewer-styles.css";
const DEFAULT_PRELOADED_IMAGES_NUM = 5;
const JUMP_WINDOW_SIZE = 3; // Number of pages to load before and after target when jumping
const calculateOptimalWidth = (
containerWidth: number,
metadata: { width: number; height: number } | null,
isMobile: boolean,
isTablet: boolean,
) => {
if (!metadata) {
// Fallback dimensions if metadata is null
return isMobile ? containerWidth : Math.min(800, containerWidth * 0.6);
}
const aspectRatio = metadata.width / metadata.height;
const maxWidth = Math.min(1400, containerWidth); // 100% of container width, max 1400px
const minWidth = Math.min(
800,
isTablet ? containerWidth * 0.9 : containerWidth * 0.6,
); // 60% of container width, min 600px
// For landscape documents (width > height), use more width
if (aspectRatio > 1) {
return maxWidth;
}
// For portrait documents, use full width on mobile, min width on desktop
return isMobile ? containerWidth : minWidth;
};
export default function PagesVerticalViewer({
pages,
feedbackEnabled,
screenshotProtectionEnabled,
versionNumber,
showPoweredByBanner,
enableQuestion = false,
feedback,
viewerEmail,
watermarkConfig,
ipAddress,
linkName,
navData,
}: {
pages: {
file: string;
pageNumber: string;
embeddedLinks: string[];
pageLinks: {
href: string;
coords: string;
isInternal?: boolean;
targetPage?: number;
}[];
metadata: { width: number; height: number; scaleFactor: number };
}[];
feedbackEnabled: boolean;
screenshotProtectionEnabled: boolean;
versionNumber: number;
showPoweredByBanner?: boolean;
enableQuestion?: boolean | null;
feedback?: {
id: string;
data: { question: string; type: string };
} | null;
viewerEmail?: string;
watermarkConfig?: WatermarkConfig | null;
ipAddress?: string;
linkName?: string;
navData: TNavData;
}) {
const { linkId, documentId, viewId, isPreview, dataroomId, brand } = navData;
const router = useRouter();
const numPages = pages.length;
const numPagesWithFeedback =
enableQuestion && feedback ? numPages + 1 : numPages;
const numPagesWithAccountCreation = numPagesWithFeedback;
const pageQuery = router.query.p ? Number(router.query.p) : 1;
const [pageNumber, setPageNumber] = useState<number>(() =>
pageQuery >= 1 && pageQuery <= numPages ? pageQuery : 1,
); // start on first page
const [loadedImages, setLoadedImages] = useState<boolean[]>(
new Array(numPages).fill(false),
);
const [submittedFeedback, setSubmittedFeedback] = useState<boolean>(false);
const [accountCreated, setAccountCreated] = useState<boolean>(false);
const [scale, setScale] = useState<number>(1);
const initialViewedPages = Array.from({ length: numPages }, (_, index) => ({
pageNumber: index + 1,
duration: 0,
}));
const [viewedPages, setViewedPages] =
useState<{ pageNumber: number; duration: number }[]>(initialViewedPages);
const [isWindowFocused, setIsWindowFocused] = useState(true);
const startTimeRef = useRef(Date.now());
const pageNumberRef = useRef<number>(pageNumber);
const visibilityRef = useRef<boolean>(true);
const containerRef = useRef<HTMLDivElement>(null);
const scrollActionRef = useRef<boolean>(false);
const hasTrackedDownRef = useRef<boolean>(false);
const hasTrackedUpRef = useRef<boolean>(false);
const imageRefs = useRef<(HTMLImageElement | null)[]>([]);
const [imageDimensions, setImageDimensions] = useState<
Record<number, { width: number; height: number }>
>({});
const { isMobile, isTablet } = useMediaQuery();
const scaleCoordinates = (coords: string, scaleFactor: number) => {
return coords
.split(",")
.map((coord) => parseFloat(coord) * scaleFactor)
.join(",");
};
const getScaleFactor = ({
naturalHeight,
scaleFactor,
}: {
naturalHeight: number;
scaleFactor: number;
}) => {
const containerHeight = imageDimensions[pageNumber - 1]
? imageDimensions[pageNumber - 1]!.height
: window.innerHeight - 64;
// Add a safety check to prevent division by zero
if (!naturalHeight || naturalHeight === 0) {
return scaleFactor;
}
return (scaleFactor * containerHeight) / naturalHeight;
};
useEffect(() => {
const updateImageDimensions = () => {
const newDimensions: Record<number, { width: number; height: number }> =
{};
imageRefs.current.forEach((img, index) => {
if (img) {
newDimensions[index] = {
width: img.clientWidth,
height: img.clientHeight,
};
}
});
setImageDimensions(newDimensions);
};
updateImageDimensions();
window.addEventListener("resize", updateImageDimensions);
return () => {
window.removeEventListener("resize", updateImageDimensions);
};
}, [loadedImages, pageNumber]);
// Update the previous page number after the effect hook has run
useEffect(() => {
pageNumberRef.current = pageNumber;
hasTrackedDownRef.current = false; // Reset tracking status on page number change
hasTrackedUpRef.current = false; // Reset tracking status on page number change
}, [pageNumber]);
const {
trackPageViewSafely,
resetTrackingState,
startIntervalTracking,
stopIntervalTracking,
getActiveDuration,
isInactive,
updateActivity,
} = useSafePageViewTracker({
...getTrackingOptions(),
externalStartTimeRef: startTimeRef,
});
useEffect(() => {
if (pageNumber <= numPages) {
const trackingData = {
linkId,
documentId,
viewId,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
};
startIntervalTracking(trackingData);
}
return () => {
stopIntervalTracking();
};
}, [
pageNumber,
numPages,
linkId,
documentId,
viewId,
versionNumber,
dataroomId,
isPreview,
startIntervalTracking,
stopIntervalTracking,
]);
useEffect(() => {
const handleVisibilityChange = () => {
if (pageNumber > numPages) return;
if (document.visibilityState === "visible") {
visibilityRef.current = true;
resetTrackingState();
if (pageNumber <= numPages) {
const trackingData = {
linkId,
documentId,
viewId,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
};
startIntervalTracking(trackingData);
}
} else {
visibilityRef.current = false;
stopIntervalTracking();
// Track final duration using activity-aware calculation
if (pageNumber <= numPages) {
const duration = getActiveDuration();
trackPageViewSafely(
{
linkId,
documentId,
viewId,
duration,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
},
true,
);
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [
pageNumber,
numPages,
linkId,
documentId,
viewId,
versionNumber,
dataroomId,
isPreview,
trackPageViewSafely,
resetTrackingState,
startIntervalTracking,
stopIntervalTracking,
getActiveDuration,
]);
useEffect(() => {
const handleBeforeUnload = () => {
stopIntervalTracking();
if (pageNumber <= numPages) {
const duration = getActiveDuration();
trackPageViewSafely(
{
linkId,
documentId,
viewId,
duration,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
},
true,
);
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [
pageNumber,
numPages,
linkId,
documentId,
viewId,
versionNumber,
dataroomId,
isPreview,
trackPageViewSafely,
stopIntervalTracking,
getActiveDuration,
]);
// Add this effect near your other useEffect hooks
useEffect(() => {
if (!screenshotProtectionEnabled) return;
const handleFocus = () => setIsWindowFocused(true);
const handleBlur = () => setIsWindowFocused(false);
window.addEventListener("focus", handleFocus);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
};
}, [screenshotProtectionEnabled]);
useEffect(() => {
setLoadedImages((prev) =>
prev.map((loaded, index) =>
index < DEFAULT_PRELOADED_IMAGES_NUM ? true : loaded,
),
);
}, []); // Run once on mount
useEffect(() => {
// Remove token and email query parameters on component mount
const removeQueryParams = (queries: string[]) => {
const currentQuery = { ...router.query };
const currentPath = router.asPath.split("?")[0];
queries.map((query) => delete currentQuery[query]);
router.replace(
{
pathname: currentPath,
query: currentQuery,
},
undefined,
{ shallow: true },
);
};
if (!dataroomId && router.query.token) {
removeQueryParams(["token", "email", "domain", "slug", "linkId"]);
}
}, []); // Run once on mount
const handleScroll = () => {
const container = containerRef.current;
if (!container) return;
const scrollPosition = container.scrollTop;
const containerHeight = container.clientHeight;
const containerRect = container.getBoundingClientRect();
// Always preload surrounding pages during scroll
const startPage = Math.max(0, pageNumber - 2 - 1);
const endPage = Math.min(numPages - 1, pageNumber + 2 - 1);
setLoadedImages((prev) => {
const newLoadedImages = [...prev];
for (let i = startPage; i <= endPage; i++) {
newLoadedImages[i] = true;
}
return newLoadedImages;
});
// Find which page is most visible in the viewport
let maxVisiblePage = pageNumber;
let maxVisibleArea = 0;
imageRefs.current.forEach((img, index) => {
if (!img) return;
const rect = img.getBoundingClientRect();
const visibleHeight =
Math.min(rect.bottom, containerRect.bottom) -
Math.max(rect.top, containerRect.top);
const visibleArea = Math.max(0, visibleHeight);
if (visibleArea > maxVisibleArea) {
maxVisibleArea = visibleArea;
maxVisiblePage = index + 1;
}
});
const feedbackElement = document.getElementById("feedback-question");
if (feedbackElement) {
const feedbackRect = feedbackElement.getBoundingClientRect();
const isFeedbackVisible =
feedbackRect.top < containerRect.bottom &&
feedbackRect.bottom > containerRect.top;
if (isFeedbackVisible) {
setPageNumber(numPagesWithFeedback);
pageNumberRef.current = numPagesWithFeedback;
startTimeRef.current = Date.now();
return;
}
}
if (maxVisiblePage !== pageNumber) {
if (pageNumber <= numPages) {
const duration = getActiveDuration();
trackPageViewSafely({
linkId,
documentId,
viewId,
duration,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
});
}
setPageNumber(maxVisiblePage);
pageNumberRef.current = maxVisiblePage;
startTimeRef.current = Date.now();
}
};
// Function to preload next image
const preloadImage = (index: number) => {
if (index < numPages && !loadedImages[index]) {
const newLoadedImages = [...loadedImages];
newLoadedImages[index] = true;
setLoadedImages(newLoadedImages);
}
};
const goToPreviousPage = () => {
if (pageNumber <= 1) return;
if (enableQuestion && feedback && pageNumber === numPagesWithFeedback) {
const targetImg = imageRefs.current[pageNumber - 2];
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
setPageNumber(pageNumber - 1);
startTimeRef.current = Date.now();
}
return;
}
if (pageNumber === numPagesWithFeedback + 1) {
const targetImg = imageRefs.current[pageNumber - 2];
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
setPageNumber(pageNumber - 1);
startTimeRef.current = Date.now();
}
return;
}
// Preload previous pages
preloadImage(pageNumber - 4);
const duration = getActiveDuration();
trackPageViewSafely({
linkId,
documentId,
viewId,
duration,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
});
const targetImg = imageRefs.current[pageNumber - 2];
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
setPageNumber(pageNumber - 1);
startTimeRef.current = Date.now();
}
};
const goToNextPage = () => {
if (pageNumber >= numPagesWithAccountCreation) return;
if (pageNumber === numPages && enableQuestion && feedback) {
const feedbackElement = document.getElementById("feedback-question");
if (feedbackElement) {
feedbackElement.scrollIntoView({ behavior: "smooth", block: "start" });
setPageNumber(numPagesWithFeedback);
startTimeRef.current = Date.now();
}
return;
}
if (pageNumber > numPages) {
const targetImg = imageRefs.current[pageNumber];
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
setPageNumber(pageNumber + 1);
startTimeRef.current = Date.now();
}
return;
}
// Preload the next page
preloadImage(pageNumber + 2);
const duration = getActiveDuration();
trackPageViewSafely({
linkId,
documentId,
viewId,
duration,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
});
const targetImg = imageRefs.current[pageNumber];
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
setPageNumber(pageNumber + 1);
startTimeRef.current = Date.now();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault(); // Prevent default behavior
event.stopPropagation(); // Stop propagation
goToNextPage();
break;
case "ArrowUp":
event.preventDefault(); // Prevent default behavior
event.stopPropagation(); // Stop propagation
goToPreviousPage();
break;
case "ArrowRight":
event.preventDefault(); // Prevent default behavior
event.stopPropagation(); // Stop propagation
goToNextPage();
break;
case "ArrowLeft":
event.preventDefault(); // Prevent default behavior
event.stopPropagation(); // Stop propagation
goToPreviousPage();
break;
default:
break;
}
};
const handleLinkClick = (href: string, event: React.MouseEvent) => {
// Check if it's an internal page link or external link
const pageMatch = href.match(/#page=(\d+)/);
if (pageMatch) {
event.preventDefault();
const targetPage = parseInt(pageMatch[1]);
if (targetPage >= 1 && targetPage <= numPages) {
// Track the current page before jumping
const duration = getActiveDuration();
trackPageViewSafely({
linkId,
documentId,
viewId,
duration,
pageNumber: pageNumber,
versionNumber,
dataroomId,
setViewedPages,
isPreview,
});
// Only load a bounded window around the target page to avoid unbounded downloads
// Unloaded pages will render as placeholders with correct height from metadata
const startPage = Math.max(0, targetPage - 1 - JUMP_WINDOW_SIZE);
const endPage = Math.min(
numPages - 1,
targetPage - 1 + JUMP_WINDOW_SIZE,
);
setLoadedImages((prev) => {
const newLoadedImages = [...prev];
// Only load pages within the window around targetPage
for (let i = startPage; i <= endPage; i++) {
newLoadedImages[i] = true;
}
return newLoadedImages;
});
setPageNumber(targetPage);
pageNumberRef.current = targetPage;
// Wait for images to load before scrolling
const waitForImageAndScroll = () => {
const targetImg = imageRefs.current[targetPage - 1];
// Check if target image exists and is loaded
if (targetImg && targetImg.complete && targetImg.naturalHeight > 0) {
scrollActionRef.current = true;
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
// If image element exists but not loaded, wait for it
if (targetImg) {
const handleLoad = () => {
scrollActionRef.current = true;
targetImg.scrollIntoView({ behavior: "smooth", block: "start" });
targetImg.removeEventListener("load", handleLoad);
};
targetImg.addEventListener("load", handleLoad);
// Timeout fallback in case image is already cached but complete wasn't set
setTimeout(() => {
targetImg.removeEventListener("load", handleLoad);
if (targetImg) {
scrollActionRef.current = true;
targetImg.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 500);
return;
}
// Image ref not available yet, wait for React to render
requestAnimationFrame(waitForImageAndScroll);
};
// Start checking after React processes the state update
requestAnimationFrame(() => {
requestAnimationFrame(waitForImageAndScroll);
});
// Reset the start time for the new page
startTimeRef.current = Date.now();
}
} else {
// Track external link clicks
if (!isPreview && viewId) {
fetch("/api/record_click", {
method: "POST",
body: JSON.stringify({
timestamp: new Date().toISOString(),
sessionId: viewId,
linkId,
documentId,
viewId,
pageNumber: pageNumber.toString(),
href,
versionNumber,
dataroomId,
}),
headers: {
"Content-Type": "application/json",
},
}).catch(console.error); // Non-blocking
}
}
};
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown, goToNextPage, goToPreviousPage]);
useEffect(() => {
if (containerRef.current) {
containerRef.current.addEventListener("scroll", handleScroll);
}
return () => {
if (containerRef.current) {
containerRef.current.removeEventListener("scroll", handleScroll);
}
};
}, [handleScroll]);
const [containerWidth, setContainerWidth] = useState<number>(0);
// Add resize observer to track container width
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.25, 3)); // Max zoom 3x
};
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.25, 0.5)); // Min zoom 0.5x
};
// Add fullscreen handler
const handleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.error("Error attempting to enable fullscreen:", err);
});
} else {
document.exitFullscreen();
}
};
// Add keyboard shortcuts for zooming and fullscreen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs or textareas
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (e.metaKey || e.ctrlKey) {
if (e.key === "=" || e.key === "+") {
e.preventDefault();
handleZoomIn();
} else if (e.key === "-") {
e.preventDefault();
handleZoomOut();
} else if (e.key === "0") {
e.preventDefault();
setScale(1);
}
} else if (e.key === "f" || e.key === "F") {
e.preventDefault();
handleFullscreen();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<>
<Nav
pageNumber={pageNumber}
numPages={numPagesWithAccountCreation}
embeddedLinks={pages[pageNumber - 1]?.embeddedLinks}
hasWatermark={watermarkConfig ? true : false}
handleZoomIn={handleZoomIn}
handleZoomOut={handleZoomOut}
handleFullscreen={handleFullscreen}
navData={navData}
/>
<div
style={{ height: "calc(100dvh - 64px)" }}
className="relative overflow-hidden"
>
<ResizablePanelGroup direction="horizontal">
{/* Document Content */}
<ResizablePanel defaultSize={100}>
<div
className={cn(
"h-full w-full",
"overflow-auto scroll-smooth",
!isWindowFocused &&
screenshotProtectionEnabled &&
"blur-xl transition-all duration-300",
)}
ref={containerRef}
>
<div className="flex min-h-full min-w-full justify-center">
<div
className="flex w-full max-w-[1400px] justify-center"
style={{
minWidth: scale > 1 ? `${100 * scale}%` : "100%",
}}
>
<div
className="transform-container w-full"
style={{
transform: `scale(${scale})`,
transition: "transform 0.2s ease-out",
transformOrigin: "center top",
}}
>
<div
className="flex flex-col items-center gap-2"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{pages.map((page, index) => {
const optimalWidth = containerWidth
? calculateOptimalWidth(
containerWidth,
page.metadata,
isMobile,
isTablet,
)
: 800;
// Calculate placeholder height from metadata aspect ratio
const placeholderHeight =
page.metadata && page.metadata.width > 0
? (optimalWidth * page.metadata.height) /
page.metadata.width
: 600; // fallback height
if (!loadedImages[index]) {
// Render a placeholder div with correct dimensions to preserve scroll height
return (
<div
key={index}
className="relative w-full px-4 md:px-8"
style={{
width: `${optimalWidth}px`,
}}
>
<div
className="viewer-container relative border-b border-t border-gray-100 bg-gray-50"
style={{
height: `${placeholderHeight}px`,
}}
/>
</div>
);
}
return (
<div
key={index}
className="relative w-full px-4 md:px-8"
style={{
width: `${optimalWidth}px`,
}}
>
<div className="viewer-container relative border-b border-t border-gray-100">
<div
className="pointer-events-none absolute bottom-0 left-0 w-px"
style={{
height: "10%",
background:
"linear-gradient(to top, #f3f4f6, transparent)",
}}
/>
<div
className="pointer-events-none absolute bottom-0 right-0 w-px"
style={{
height: "10%",
background:
"linear-gradient(to top, #f3f4f6, transparent)",
}}
/>
<div
className="pointer-events-none absolute left-0 top-0 w-px"
style={{
height: "10%",
background:
"linear-gradient(to bottom, #f3f4f6, transparent)",
}}
/>
<div
className="pointer-events-none absolute right-0 top-0 w-px"
style={{
height: "10%",
background:
"linear-gradient(to bottom, #f3f4f6, transparent)",
}}
/>
<img
className="viewer-image-mobile h-auto w-full object-contain"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
ref={(ref) => {
imageRefs.current[index] = ref;
if (ref) {
ref.onload = () =>
setImageDimensions((prev) => ({
...prev,
[index]: {
width: ref.clientWidth,
height: ref.clientHeight,
},
}));
}
}}
useMap={`#page-map-${index + 1}`}
src={page.file}
alt={`Page ${index + 1}`}
/>
{watermarkConfig && imageDimensions[index] ? (
<div className="absolute left-0 top-0">
<SVGWatermark
config={watermarkConfig}
viewerData={{
email: viewerEmail,
date: new Date().toLocaleDateString(),
time: new Date().toLocaleTimeString(),
link: linkName,
ipAddress: ipAddress,
}}
documentDimensions={imageDimensions[index]}
pageIndex={index}
/>
</div>
) : null}
</div>
{page.pageLinks ? (
<map name={`page-map-${index + 1}`}>
{page.pageLinks
.filter((link) => !link.href.endsWith(".gif"))
.map((link, linkIndex) => (
<area
key={linkIndex}
shape="rect"
coords={scaleCoordinates(
link.coords,
getScaleFactor({
naturalHeight: page.metadata.height,
scaleFactor:
page.metadata.scaleFactor,
}),
)}
href={link.href}
onClick={(e) =>
handleLinkClick(link.href, e)
}
target={
link.href.startsWith("#")
? "_self"
: "_blank"
}
rel={
link.href.startsWith("#")
? undefined
: "noopener noreferrer"
}
/>
))}
</map>
) : null}
{page.pageLinks && imageDimensions[index]
? page.pageLinks
.filter((link) => link.href.endsWith(".gif"))
.map((link, linkIndex) => {
const [x1, y1, x2, y2] = scaleCoordinates(
link.coords,
getScaleFactor({
naturalHeight: page.metadata.height,
scaleFactor: page.metadata.scaleFactor,
}),
)
.split(",")
.map(Number);
const overlayWidth = x2 - x1;
const overlayHeight = y2 - y1;
// Account for the padding on the outer container (px-4 md:px-8)
const padding = isMobile ? 16 : 32; // 1rem = 16px (px-4), 2rem = 32px (px-8)
return (
<img
key={`overlay-${index}-${linkIndex}`}
src={link.href}
alt={`Overlay ${index + 1}`}
style={{
position: "absolute",
top: y1,
left: x1 + padding,
width: `${overlayWidth}px`,
height: `${overlayHeight}px`,
pointerEvents: "none",
}}
/>
);
})
: null}
</div>
);
})}
{enableQuestion &&
feedback &&
pageNumber >= numPagesWithFeedback - 1 && (
<div
id="feedback-question"
className={cn("relative block h-dvh w-full")}
style={{ height: "calc(100dvh - 64px)" }}
>
<Question
accentColor={brand?.accentColor}
feedback={feedback}
viewId={viewId}
submittedFeedback={submittedFeedback}
setSubmittedFeedback={setSubmittedFeedback}
isPreview={isPreview}
/>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Up arrow - hide on first page */}
<div
className={cn(
"absolute left-0 right-0 top-0 flex h-24 items-start justify-center pt-4 transition-opacity duration-200",
pageNumber <= 1 ? "hidden" : "opacity-0 hover:opacity-100",
)}
onClick={goToPreviousPage}
>
<button
disabled={pageNumber <= 1}
className="rounded-full bg-gray-950/50 p-1 hover:bg-gray-950/75"
>
<ChevronUpIcon className="h-10 w-10 text-white" />
</button>
</div>
{/* Down arrow - hide on last page unless there's an account creation page */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 flex h-24 items-end justify-center pb-4 transition-opacity duration-200",
pageNumber >= numPagesWithAccountCreation
? "hidden"
: "opacity-0 hover:opacity-100",
)}
onClick={goToNextPage}
>
<button
disabled={pageNumber >= numPagesWithAccountCreation}
className="rounded-full bg-gray-950/50 p-1 hover:bg-gray-950/75"
>
<ChevronDownIcon className="h-10 w-10 text-white" />
</button>
</div>
{feedbackEnabled && pageNumber <= numPages ? (
<Toolbar
viewId={viewId}
pageNumber={pageNumber}
isPreview={isPreview}
/>
) : null}
{screenshotProtectionEnabled ? <ScreenProtector /> : null}
{showPoweredByBanner ? <PoweredBy linkId={linkId} /> : null}
<AwayPoster
isVisible={isInactive}
inactivityThreshold={
getTrackingOptions().inactivityThreshold || 20000
}
onDismiss={updateActivity}
/>
{/* </div> */}
{/* </div> */}
</ResizablePanel>
</ResizablePanelGroup>
</div>
</>
);
}