import React, { useEffect, useRef, useState } from "react"; import clsx from "clsx"; import DOMPurify from "dompurify"; import { PreservationSkeleton } from "../Skeletons"; import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; import Link from "next/link"; import unescapeString from "@/lib/client/unescapeString"; import isValidUrl from "@/lib/shared/isValidUrl"; import LinkDate from "../LinkViews/LinkComponents/LinkDate"; import usePermissions from "@/hooks/usePermissions"; import { LinkIncludingShortenedCollectionAndTags, ArchivedFormat, } from "@linkwarden/types/global"; import { readerViewCSS, READER_VIEW_DEFAULTS, } from "@linkwarden/lib/readerViewStyles"; import ClickAwayHandler from "@/components/ClickAwayHandler"; import { useGetLinkHighlights, usePostHighlight, useRemoveHighlight, } from "@linkwarden/router/highlights"; import { Highlight } from "@linkwarden/prisma/client"; import { useUser } from "@linkwarden/router/user"; import { Caveat } from "next/font/google"; import { Bentham } from "next/font/google"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; const caveat = Caveat({ subsets: ["latin"] }); const bentham = Bentham({ subsets: ["latin"], weight: "400" }); type Props = { link: LinkIncludingShortenedCollectionAndTags; }; export default function ReadableView({ link }: Props) { const { t } = useTranslation(); const router = useRouter(); const isPublicRoute = router.pathname.startsWith("/public"); const permissions = usePermissions(link?.collection?.id as number); const postHighlight = usePostHighlight(link?.id as number); const { data: linkHighlights } = useGetLinkHighlights(link?.id as number); const deleteHighlight = useRemoveHighlight(link?.id as number); const [isCommenting, setIsCommenting] = useState(false); const [selectionInfo, setSelectionInfo] = useState<{ highlightId?: number | null; linkId: number; text: string; startOffset: number; endOffset: number; color: "yellow" | "red" | "blue" | "green"; comment?: string; } | null>(null); const [linkContent, setLinkContent] = useState(""); const [menuOpen, setMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); useEffect(() => { async function fetchLinkContent() { if (link?.readable?.startsWith("archives")) { const response = await fetch( `/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}&_=${link.updatedAt}` ); const data = await response.json(); setLinkContent(DOMPurify.sanitize(data?.content ?? "")); } } fetchLinkContent(); }, [link]); const { data: user } = useUser(); useEffect(() => { if (selectionInfo?.highlightId) { const comment = linkHighlights?.find( (h) => h.id === selectionInfo.highlightId )?.comment; if (selectionInfo) { setSelectionInfo({ ...selectionInfo, comment: comment || "" }); } } }, [selectionInfo?.highlightId, linkHighlights, isCommenting]); const handleMouseUp = (e: React.MouseEvent) => { const containerEl = containerRef.current; if (!containerEl) return; const containerRect = containerEl.getBoundingClientRect(); const target = e.target as HTMLElement; const highlightId = Number(target.dataset.highlightId); const selection = window.getSelection(); const color = linkHighlights?.find((e) => e.id == highlightId)?.color; const info = getHighlightedSection(); if (!menuOpen) setSelectionInfo({ highlightId: highlightId || null, linkId: link.id as number, text: info?.text || "", startOffset: info?.startOffset || -1, endOffset: info?.endOffset || -1, color: color as any, }); if (highlightId) { const rect = target.getBoundingClientRect(); const relativeX = rect.left - containerRect.left + rect.width / 2; const relativeY = rect.top - containerRect.top - 5; setMenuOpen(true); setMenuPosition({ x: relativeX, y: relativeY, }); return; } if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); if (rect && rect.width && rect.height) { const relativeX = rect.left - containerRect.left + rect.width / 2; const relativeY = rect.top - containerRect.top - 5; setMenuPosition({ x: relativeX, y: relativeY, }); setMenuOpen(true); } } }; function getHighlightedSection() { const selection = window.getSelection?.(); if (!selection || selection.isCollapsed) return null; const range = selection.getRangeAt(0); if (!range) return null; const container = document.getElementById("readable-view"); if (!container || !container.contains(range.commonAncestorContainer)) { return null; } let startOffset = -1; let endOffset = -1; let currentOffset = 0; const treeWalker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT ); while (treeWalker.nextNode()) { const node = treeWalker.currentNode; const nodeLength = node.textContent?.length ?? 0; if (node === range.startContainer) { startOffset = currentOffset + range.startOffset; } if (node === range.endContainer) { endOffset = currentOffset + range.endOffset; break; } currentOffset += nodeLength; } if (startOffset === -1 || endOffset === -1) { return null; } return { linkId: link.id as number, text: range.toString(), startOffset, endOffset, }; } function getHighlightedHtml( htmlContent: string, highlights: Highlight[] ): string { if (!htmlContent || !highlights || highlights.length === 0) { return htmlContent; } const container = document.createElement("div"); container.innerHTML = htmlContent; const sortedHighlights = [...highlights].sort( (a, b) => a.startOffset - b.startOffset ); for (const highlight of sortedHighlights) { applyHighlight(container, highlight); } return container.innerHTML; } function applyHighlight(container: HTMLElement, highlight: Highlight) { let currentOffset = 0; const treeWalker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT ); const rangesToWrap: Array<{ node: Text; start: number; end: number; }> = []; while (treeWalker.nextNode()) { const node = treeWalker.currentNode as Text; const nodeLength = node.textContent?.length ?? 0; const nodeStart = currentOffset; const nodeEnd = nodeStart + nodeLength; if (nodeStart < highlight.endOffset && nodeEnd > highlight.startOffset) { rangesToWrap.push({ node, start: Math.max(0, highlight.startOffset - nodeStart), end: Math.min(nodeLength, highlight.endOffset - nodeStart), }); } currentOffset += nodeLength; } rangesToWrap.forEach(({ node, start, end }) => { if (start > 0) { node.splitText(start); node = node.nextSibling as Text; end -= start; } if (end < node.length) { node.splitText(end); } const highlightWrapper = document.createElement("span"); highlightWrapper.id = `highlight-${highlight.id}`; highlightWrapper.dataset.highlightId = highlight.id.toString(); highlightWrapper.classList.add( "cursor-pointer", "bg-opacity-60", "hover:bg-opacity-80", "duration-150" ); if (highlight.comment) highlightWrapper.classList.add("border-b-2"); if (highlight.color === "yellow") { highlightWrapper.classList.add("bg-yellow-500", "border-yellow-500"); } else if (highlight.color === "red") { highlightWrapper.classList.add("bg-red-500", "border-red-500"); } else if (highlight.color === "blue") { highlightWrapper.classList.add("bg-blue-500", "border-blue-500"); } else if (highlight.color === "green") { highlightWrapper.classList.add("bg-green-500", "border-green-500"); } node.parentNode?.insertBefore(highlightWrapper, node); highlightWrapper.appendChild(node); }); } const highlightedHtml = React.useMemo(() => { return getHighlightedHtml(linkContent, linkHighlights || []); }, [linkContent, linkHighlights]); useEffect(() => { if (!user) return; const container = containerRef.current; if (!container) return; const getFont = () => { if (user.readableFontFamily === "caveat") { return caveat.style.fontFamily; } else if (user.readableFontFamily === "bentham") { return bentham.style.fontFamily; } else return user.readableFontFamily; }; const pFontSize = user.readableFontSize || `${READER_VIEW_DEFAULTS.fontSize}px`; const ratio = parseInt(pFontSize) / READER_VIEW_DEFAULTS.fontSize; container.style.setProperty("--rv-p-font-size", pFontSize); container.style.setProperty( "--rv-p-line-height", user.readableLineHeight || String(READER_VIEW_DEFAULTS.lineHeight) ); container.style.setProperty( "--rv-h1-font-size", READER_VIEW_DEFAULTS.h1Size * ratio + "px" ); container.style.setProperty( "--rv-h2-font-size", READER_VIEW_DEFAULTS.h2Size * ratio + "px" ); container.style.setProperty( "--rv-h3-font-size", READER_VIEW_DEFAULTS.h3Size * ratio + "px" ); container.style.setProperty( "--rv-h4-font-size", READER_VIEW_DEFAULTS.h4Size * ratio + "px" ); container.style.setProperty( "--rv-h5-font-size", READER_VIEW_DEFAULTS.h5Size * ratio + "px" ); container.style.fontFamily = `${getFont()}`; }, [ user?.theme, user?.readableFontFamily, user?.readableFontSize, user?.readableLineHeight, ]); const handleHighlightSelection = async ( highlightId?: number | null, color?: "yellow" | "red" | "blue" | "green" ) => { if ( !highlightId && (!selectionInfo?.text || selectionInfo.startOffset === -1 || selectionInfo.endOffset === -1) ) return; if (selectionInfo) setSelectionInfo({ ...selectionInfo, color: color as "yellow" | "red" | "blue" | "green", }); let selection = selectionInfo; if (highlightId) { selection = (linkHighlights?.find( (h) => h.id === selectionInfo?.highlightId ) ?? null) as any; } if (!selection && !highlightId) return; postHighlight.mutate( { ...selection, color: color || selectionInfo?.color || "yellow", comment: selectionInfo?.comment, } as Highlight, { onSuccess: (data) => { if (data) { setMenuOpen(true); if (selectionInfo) { setSelectionInfo({ ...selectionInfo, highlightId: data.id, linkId: selectionInfo.linkId, text: selectionInfo.text, startOffset: selectionInfo.startOffset, endOffset: selectionInfo.endOffset, color: data.color as any, comment: selectionInfo.comment, }); } } }, } ); }; const handleMenuClickOutside = () => { setMenuOpen(false); setSelectionInfo(null); setIsCommenting(false); if (selectionInfo) setSelectionInfo({ ...selectionInfo, comment: "", }); if (window.getSelection) { window.getSelection()?.removeAllRanges(); } }; const containerRef = useRef(null); return ( <>