mirror of
https://github.com/hanzoai/dataroom.git
synced 2026-10-30 18:41:07 +00:00
596 lines
18 KiB
TypeScript
596 lines
18 KiB
TypeScript
import dynamic from "next/dynamic";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import React from "react";
|
|
|
|
import { Slash } from "lucide-react";
|
|
import { ExtendedRecordMap } from "notion-types";
|
|
import { useQueryState } from "nuqs";
|
|
import { NotionRenderer } from "react-notion-x";
|
|
// core styles shared by all of react-notion-x (required)
|
|
import "react-notion-x/src/styles.css";
|
|
|
|
import { useSafePageViewTracker } from "@/lib/tracking/safe-page-view-tracker";
|
|
import { getTrackingOptions } from "@/lib/tracking/tracking-config";
|
|
import { NotionTheme } from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
|
|
|
import {
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator,
|
|
} from "@/components/ui/breadcrumb";
|
|
import { Portal } from "@/components/ui/portal";
|
|
|
|
import { ScreenProtector } from "../../view/ScreenProtection";
|
|
import Nav, { TNavData } from "../../view/nav";
|
|
import { AwayPoster } from "./away-poster";
|
|
|
|
// custom styles for notion
|
|
import "@/styles/custom-notion-styles.css";
|
|
|
|
const Collection = dynamic(() =>
|
|
import("react-notion-x/build/third-party/collection").then(
|
|
(m) => m.Collection,
|
|
),
|
|
);
|
|
|
|
const Code = dynamic(() =>
|
|
import("react-notion-x/build/third-party/code").then((m) => m.Code),
|
|
);
|
|
|
|
// Obfuscate Notion block IDs in the DOM to hide the original Notion page IDs
|
|
const obfuscateNotionIds = (container: HTMLElement) => {
|
|
// Pattern to match Notion-style UUIDs (with or without hyphens)
|
|
const uuidPattern =
|
|
/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
|
|
|
|
// Create a map to consistently replace the same ID with the same obfuscated value
|
|
const idMap = new Map<string, string>();
|
|
let counter = 0;
|
|
|
|
const getObfuscatedId = (originalId: string): string => {
|
|
const normalizedId = originalId.toLowerCase().replace(/-/g, "");
|
|
if (!idMap.has(normalizedId)) {
|
|
idMap.set(normalizedId, `block-${counter++}`);
|
|
}
|
|
return idMap.get(normalizedId)!;
|
|
};
|
|
|
|
// Obfuscate element IDs
|
|
const elementsWithId = container.querySelectorAll("[id]");
|
|
elementsWithId.forEach((el) => {
|
|
const id = el.getAttribute("id");
|
|
if (id && uuidPattern.test(id)) {
|
|
const newId = id.replace(uuidPattern, (match) => getObfuscatedId(match));
|
|
el.setAttribute("id", newId);
|
|
}
|
|
// Reset the pattern lastIndex
|
|
uuidPattern.lastIndex = 0;
|
|
});
|
|
|
|
// Obfuscate data-block-id attributes
|
|
const elementsWithBlockId = container.querySelectorAll("[data-block-id]");
|
|
elementsWithBlockId.forEach((el) => {
|
|
const blockId = el.getAttribute("data-block-id");
|
|
if (blockId && uuidPattern.test(blockId)) {
|
|
el.setAttribute("data-block-id", getObfuscatedId(blockId));
|
|
}
|
|
uuidPattern.lastIndex = 0;
|
|
});
|
|
|
|
// Obfuscate data-id attributes
|
|
const elementsWithDataId = container.querySelectorAll("[data-id]");
|
|
elementsWithDataId.forEach((el) => {
|
|
const dataId = el.getAttribute("data-id");
|
|
if (dataId && uuidPattern.test(dataId)) {
|
|
el.setAttribute("data-id", getObfuscatedId(dataId));
|
|
}
|
|
uuidPattern.lastIndex = 0;
|
|
});
|
|
|
|
// Obfuscate class names that contain Notion IDs
|
|
const allElements = container.querySelectorAll("*");
|
|
allElements.forEach((el) => {
|
|
const classList = el.className;
|
|
if (typeof classList === "string" && uuidPattern.test(classList)) {
|
|
const newClassList = classList.replace(uuidPattern, (match) =>
|
|
getObfuscatedId(match),
|
|
);
|
|
el.className = newClassList;
|
|
}
|
|
uuidPattern.lastIndex = 0;
|
|
});
|
|
|
|
// Obfuscate anchor href attributes that contain hash references to Notion IDs
|
|
// This is important for table of contents links to work after ID obfuscation
|
|
const anchorsWithHash = container.querySelectorAll('a[href^="#"]');
|
|
anchorsWithHash.forEach((anchor) => {
|
|
const href = anchor.getAttribute("href");
|
|
if (href) {
|
|
// Extract the hash part (remove the leading #)
|
|
const hashId = href.slice(1);
|
|
if (uuidPattern.test(hashId)) {
|
|
const newHashId = hashId.replace(uuidPattern, (match) =>
|
|
getObfuscatedId(match),
|
|
);
|
|
anchor.setAttribute("href", `#${newHashId}`);
|
|
}
|
|
uuidPattern.lastIndex = 0;
|
|
}
|
|
});
|
|
};
|
|
|
|
export const NotionPage = ({
|
|
recordMap,
|
|
versionNumber,
|
|
theme,
|
|
screenshotProtectionEnabled,
|
|
textSelectionEnabled,
|
|
navData,
|
|
}: {
|
|
recordMap: ExtendedRecordMap;
|
|
versionNumber: number;
|
|
theme?: NotionTheme | null;
|
|
screenshotProtectionEnabled: boolean;
|
|
textSelectionEnabled: boolean;
|
|
navData: TNavData;
|
|
}) => {
|
|
const { isPreview, linkId, documentId, viewId, brand } = navData;
|
|
const navPalette = createAdaptiveSurfacePalette(brand?.brandColor);
|
|
const [pageNumber, setPageNumber] = useState<number>(1); // start on first page
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [subPageId, setSubPageId] = useQueryState("pageid", {
|
|
history: "push",
|
|
scroll: true,
|
|
});
|
|
const [subTitle, setSubTitle] = useState<string>("");
|
|
const [title, setTitle] = useState<string>("");
|
|
|
|
const [isWindowFocused, setIsWindowFocused] = useState(true);
|
|
|
|
const [recordMapState, setRecordMapState] =
|
|
useState<ExtendedRecordMap>(recordMap);
|
|
|
|
const notionContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Create a cache object to store fetched recordMaps
|
|
const recordMapCache = useRef<{ [key: string]: ExtendedRecordMap }>({});
|
|
|
|
const startTimeRef = useRef(Date.now());
|
|
const pageNumberRef = useRef<number>(pageNumber);
|
|
const visibilityRef = useRef<boolean>(true);
|
|
const trackingOptions = getTrackingOptions();
|
|
const {
|
|
trackPageViewSafely,
|
|
resetTrackingState,
|
|
startIntervalTracking,
|
|
stopIntervalTracking,
|
|
getActiveDuration,
|
|
isInactive,
|
|
updateActivity,
|
|
} = useSafePageViewTracker({
|
|
...trackingOptions,
|
|
externalStartTimeRef: startTimeRef,
|
|
});
|
|
|
|
// Start interval tracking when component mounts
|
|
useEffect(() => {
|
|
const trackingData = {
|
|
linkId: linkId,
|
|
documentId: documentId,
|
|
viewId: viewId,
|
|
pageNumber: pageNumberRef.current,
|
|
versionNumber: versionNumber,
|
|
isPreview: isPreview,
|
|
dataroomId: navData?.dataroomId || undefined,
|
|
};
|
|
|
|
startIntervalTracking(trackingData);
|
|
|
|
return () => {
|
|
stopIntervalTracking();
|
|
};
|
|
}, [
|
|
linkId,
|
|
documentId,
|
|
viewId,
|
|
versionNumber,
|
|
isPreview,
|
|
startIntervalTracking,
|
|
stopIntervalTracking,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "visible") {
|
|
visibilityRef.current = true;
|
|
startTimeRef.current = Date.now(); // Reset start time when page becomes visible
|
|
resetTrackingState();
|
|
|
|
// Restart interval tracking
|
|
const trackingData = {
|
|
linkId: linkId,
|
|
documentId: documentId,
|
|
viewId: viewId,
|
|
pageNumber: pageNumberRef.current,
|
|
versionNumber: versionNumber,
|
|
isPreview: isPreview,
|
|
dataroomId: navData?.dataroomId || undefined,
|
|
};
|
|
startIntervalTracking(trackingData);
|
|
} else {
|
|
visibilityRef.current = false;
|
|
stopIntervalTracking();
|
|
|
|
// Track final duration using activity-aware calculation
|
|
const duration = getActiveDuration();
|
|
trackPageViewSafely(
|
|
{
|
|
linkId: linkId,
|
|
documentId: documentId,
|
|
viewId: viewId,
|
|
duration: duration,
|
|
pageNumber: pageNumberRef.current,
|
|
versionNumber: versionNumber,
|
|
isPreview: isPreview,
|
|
dataroomId: navData?.dataroomId || undefined,
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [
|
|
linkId,
|
|
documentId,
|
|
viewId,
|
|
versionNumber,
|
|
isPreview,
|
|
navData,
|
|
trackPageViewSafely,
|
|
resetTrackingState,
|
|
startIntervalTracking,
|
|
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]);
|
|
|
|
// Memoize the fetchSubPage function
|
|
const fetchSubPage = useCallback(
|
|
async (pageId: string | null) => {
|
|
if (pageId) {
|
|
// Check if the recordMap is already in the cache
|
|
if (recordMapCache.current[pageId]) {
|
|
const currentRecordMap = recordMapCache.current[pageId];
|
|
setRecordMapState(currentRecordMap);
|
|
const firstBlockId = Object.keys(currentRecordMap.block)[0];
|
|
const firstBlock = currentRecordMap.block[firstBlockId];
|
|
setSubTitle(
|
|
firstBlock?.value?.properties?.title?.[0]?.[0] || "Untitled",
|
|
);
|
|
// Scroll to top when changing subpages
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch("/api/file/notion", {
|
|
method: "POST",
|
|
body: JSON.stringify({ pageId }),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
const newRecordMap = await response.json();
|
|
// Store the fetched recordMap in the cache
|
|
recordMapCache.current[pageId] = newRecordMap;
|
|
setRecordMapState(newRecordMap);
|
|
const firstBlockId = Object.keys(newRecordMap.block)[0];
|
|
const firstBlock = recordMap.block[firstBlockId];
|
|
setSubTitle(
|
|
firstBlock?.value?.properties?.title?.[0]?.[0] || "Untitled",
|
|
);
|
|
// Scroll to top after loading new subpage
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
} catch (error) {
|
|
console.error("Error fetching subpage:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
} else {
|
|
setRecordMapState(recordMap);
|
|
// get the first item in the recordMap.block object
|
|
const firstBlockId = Object.keys(recordMap.block)[0];
|
|
const firstBlock = recordMap.block[firstBlockId];
|
|
setTitle(firstBlock?.value?.properties?.title?.[0]?.[0] || "Untitled");
|
|
// Scroll to top when returning to main page
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
},
|
|
[subPageId, recordMap],
|
|
);
|
|
|
|
// Use useMemo to memoize the effect of fetching the subpage
|
|
useMemo(() => {
|
|
fetchSubPage(subPageId);
|
|
}, [subPageId, fetchSubPage]);
|
|
|
|
useEffect(() => {
|
|
const handleBeforeUnload = () => {
|
|
stopIntervalTracking();
|
|
const duration = getActiveDuration();
|
|
trackPageViewSafely(
|
|
{
|
|
linkId: linkId,
|
|
documentId: documentId,
|
|
viewId: viewId,
|
|
duration: duration,
|
|
pageNumber: pageNumberRef.current,
|
|
versionNumber: versionNumber,
|
|
isPreview: isPreview,
|
|
dataroomId: navData?.dataroomId || undefined,
|
|
},
|
|
true,
|
|
);
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
};
|
|
}, [
|
|
linkId,
|
|
documentId,
|
|
viewId,
|
|
versionNumber,
|
|
isPreview,
|
|
trackPageViewSafely,
|
|
stopIntervalTracking,
|
|
getActiveDuration,
|
|
]);
|
|
|
|
// // Function to calculate scroll percentage
|
|
// const calculateScrollPercentage = () => {
|
|
// const scrollableHeight =
|
|
// document.documentElement.scrollHeight - window.innerHeight;
|
|
// const currentScrollPosition = window.scrollY;
|
|
// return (currentScrollPosition / scrollableHeight) * 100;
|
|
// };
|
|
|
|
// // Function to handle scroll events
|
|
// const handleScroll = () => {
|
|
// const scrollPercent = calculateScrollPercentage();
|
|
// setMaxScrollPercentage((prevMax) => Math.max(prevMax, scrollPercent));
|
|
|
|
// const data = {
|
|
// x: window.scrollX,
|
|
// y: window.scrollY,
|
|
// scrollPercentage: scrollPercent,
|
|
// type: "scroll",
|
|
// };
|
|
|
|
// // TODO: Store data for later use with heatmap.js
|
|
// };
|
|
|
|
// useEffect(() => {
|
|
// // Add scroll event listener
|
|
// window.addEventListener("scroll", handleScroll);
|
|
|
|
// // Remove event listener on cleanup
|
|
// return () => {
|
|
// window.removeEventListener("scroll", handleScroll);
|
|
// };
|
|
// }, [maxScrollPercentage]);
|
|
|
|
// Add a function to handle smooth scrolling to elements
|
|
const scrollToHashElement = useCallback(() => {
|
|
const hash = window.location.hash;
|
|
if (hash) {
|
|
// Remove the # from the hash
|
|
const elementId = hash.slice(1);
|
|
|
|
// Create observer to watch for position changes
|
|
const observer = new MutationObserver((mutations, obs) => {
|
|
const element = document.getElementById(elementId);
|
|
if (element) {
|
|
// Get current position
|
|
const rect = element.getBoundingClientRect();
|
|
const absoluteTop = window.scrollY + rect.top; // Account for header
|
|
|
|
window.scrollTo({
|
|
top: absoluteTop,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
});
|
|
|
|
// Start observing the document with the configured parameters
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
});
|
|
|
|
// Always observe for at least 2 seconds to catch any layout shifts
|
|
setTimeout(() => {
|
|
const element = document.getElementById(elementId);
|
|
if (element) {
|
|
const rect = element.getBoundingClientRect();
|
|
const absoluteTop = window.scrollY + rect.top;
|
|
window.scrollTo({
|
|
top: absoluteTop,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
observer.disconnect();
|
|
}, 2000);
|
|
}
|
|
}, []);
|
|
|
|
// Handle initial load and hash changes
|
|
useEffect(() => {
|
|
scrollToHashElement();
|
|
|
|
window.addEventListener("hashchange", scrollToHashElement);
|
|
return () => {
|
|
window.removeEventListener("hashchange", scrollToHashElement);
|
|
};
|
|
}, [scrollToHashElement]);
|
|
|
|
// Obfuscate Notion IDs in the DOM after rendering
|
|
useEffect(() => {
|
|
if (!notionContainerRef.current) return;
|
|
|
|
// Initial obfuscation
|
|
const timeoutId = setTimeout(() => {
|
|
if (notionContainerRef.current) {
|
|
obfuscateNotionIds(notionContainerRef.current);
|
|
}
|
|
}, 100);
|
|
|
|
// Set up MutationObserver to handle dynamically loaded content
|
|
const observer = new MutationObserver((mutations) => {
|
|
// Debounce the obfuscation to avoid excessive processing
|
|
if (notionContainerRef.current) {
|
|
obfuscateNotionIds(notionContainerRef.current);
|
|
}
|
|
});
|
|
|
|
observer.observe(notionContainerRef.current, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: false,
|
|
});
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
observer.disconnect();
|
|
};
|
|
}, [recordMapState]);
|
|
|
|
if (!recordMap) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white">
|
|
<Nav type="notion" navData={navData} />
|
|
|
|
<Portal
|
|
containerId="view-breadcrump-portal"
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
<>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink
|
|
className="cursor-pointer underline underline-offset-4"
|
|
onClick={() => setSubPageId(null)}
|
|
style={{
|
|
color: navPalette.textColor,
|
|
}}
|
|
>
|
|
{title}
|
|
</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
{subPageId ? (
|
|
<>
|
|
<BreadcrumbSeparator>
|
|
<Slash />
|
|
</BreadcrumbSeparator>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage
|
|
className="font-medium"
|
|
style={{
|
|
color: navPalette.textColor,
|
|
}}
|
|
>
|
|
{subTitle}
|
|
</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</>
|
|
) : null}
|
|
</>
|
|
</Portal>
|
|
|
|
{loading && <div>Loading...</div>}
|
|
|
|
<div
|
|
ref={notionContainerRef}
|
|
className={cn(
|
|
!isWindowFocused &&
|
|
screenshotProtectionEnabled &&
|
|
"blur-xl transition-all duration-300",
|
|
textSelectionEnabled && "notion-text-selection-enabled",
|
|
)}
|
|
>
|
|
<NotionRenderer
|
|
recordMap={recordMapState}
|
|
fullPage={true}
|
|
darkMode={theme ? theme === "dark" : false}
|
|
disableHeader={true}
|
|
components={{
|
|
Collection,
|
|
Code,
|
|
PageLink: (props: {
|
|
className: string;
|
|
href: any;
|
|
children:
|
|
| string
|
|
| number
|
|
| boolean
|
|
| React.ReactElement<
|
|
any,
|
|
string | React.JSXElementConstructor<any>
|
|
>
|
|
| React.ReactPortal
|
|
| null
|
|
| undefined;
|
|
}) => {
|
|
return (
|
|
<div
|
|
className={props.className}
|
|
onClick={() => setSubPageId(props.href.split("/")[1])}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
{screenshotProtectionEnabled ? <ScreenProtector /> : null}
|
|
<AwayPoster
|
|
isVisible={isInactive}
|
|
inactivityThreshold={getTrackingOptions().inactivityThreshold}
|
|
onDismiss={updateActivity}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|