Files
linkwarden/apps/web/components/Preservation/ReadableView.tsx

622 lines
21 KiB
TypeScript

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<HTMLDivElement>(null);
return (
<>
<style dangerouslySetInnerHTML={{ __html: readerViewCSS }} />
<div
ref={containerRef}
className={clsx(
"flex flex-col gap-3 items-start p-3 mx-auto bg-base-200 mt-5 relative",
user?.readableLineWidth === "narrower"
? "max-w-screen-sm"
: user?.readableLineWidth === "narrow"
? "max-w-screen-md"
: user?.readableLineWidth === "normal"
? "max-w-screen-lg"
: user?.readableLineWidth === "wide"
? "max-w-screen-xl"
: user?.readableLineWidth === "wider"
? "max-w-screen-2xl"
: ""
)}
>
<div className="reader-view">
<h1>
{unescapeString(link?.name || link?.description || link?.url || "")}
</h1>
</div>
{link?.url && (
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
>
<i className="bi-link-45deg" />
{isValidUrl(link?.url || "") && new URL(link?.url as string).host}
</Link>
)}
<div className="text-sm text-neutral flex justify-between w-full gap-2">
<LinkDate link={link} />
</div>
<Separator className="mt-5 mb-2" />
{link?.readable?.startsWith("archives") ? (
<>
{linkContent ? (
<div
className={clsx("p-3 rounded-md w-full bg-base-200")}
onMouseUp={handleMouseUp}
>
<div
id="readable-view"
className="line-break px-1 reader-view read-only"
style={{ contentVisibility: "auto" }}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
{menuOpen &&
!isPublicRoute &&
(permissions === true || permissions?.canUpdate) && (
<ClickAwayHandler
onClickOutside={handleMenuClickOutside}
className="absolute bg-base-100 p-2 z-[9999]
whitespace-nowrap -translate-x-1/2
-translate-y-full rounded-lg shadow-md border border-neutral-content"
style={{
left: menuPosition.x,
top: menuPosition.y,
}}
>
{isCommenting ? (
<div>
<textarea
value={selectionInfo?.comment}
onChange={(e) => {
if (selectionInfo)
setSelectionInfo({
...selectionInfo,
comment: e.target.value,
});
}}
placeholder={t("link_description_placeholder")}
className="resize-none w-52 rounded-md p-2 h-32 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsCommenting(false);
if (selectionInfo)
setSelectionInfo({
...selectionInfo,
comment: "",
});
}}
>
{t("cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => {
handleHighlightSelection(
selectionInfo?.highlightId
);
setIsCommenting(false);
if (selectionInfo)
setSelectionInfo({
...selectionInfo,
comment: "",
});
}}
>
{t("save")}
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-3 justify-between select-none">
{["yellow", "red", "blue", "green"].map((color) => (
<button
key={color}
onClick={() =>
handleHighlightSelection(
selectionInfo?.highlightId,
color as "yellow" | "red" | "blue" | "green"
)
}
className={`w-5 h-5 rounded-full ${
color === "yellow"
? "bg-yellow-300"
: color === "red"
? "bg-red-500"
: color === "blue"
? "bg-blue-500"
: "bg-green-500"
} hover:opacity-70 duration-100 relative`}
title={`${
color.charAt(0).toUpperCase() + color.slice(1)
} Highlight`}
>
{selectionInfo?.highlightId &&
linkHighlights?.find(
(h) => h.id === selectionInfo.highlightId
)?.color === color && (
<i className="bi-check2 text-sm text-black absolute inset-0 flex items-center justify-center" />
)}
</button>
))}
<button
className="hover:opacity-70 duration-100"
onClick={() => setIsCommenting(true)}
>
<i className="bi-chat-text" />
</button>
{selectionInfo?.highlightId && (
<button
onClick={() => {
deleteHighlight.mutate(
selectionInfo.highlightId as number
);
setSelectionInfo(null);
}}
className="hover:opacity-70 duration-100"
title="Delete"
>
<i className="bi-trash" />
</button>
)}
</div>
)}
</ClickAwayHandler>
)}
</div>
) : (
<PreservationSkeleton className="h-fit" />
)}
</>
) : (
<div className={`w-full h-full flex flex-col justify-center`}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5"
viewBox="0 0 16 16"
>
<path d="m14.12 10.163 1.715.858c.22.11.22.424 0 .534L8.267 15.34a.598.598 0 0 1-.534 0L.165 11.555a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.66zM7.733.063a.598.598 0 0 1 .534 0l7.568 3.784a.3.3 0 0 1 0 .535L8.267 8.165a.598.598 0 0 1-.534 0L.165 4.382a.299.299 0 0 1 0-.535L7.733.063z" />
<path d="m14.12 6.576 1.715.858c.22.11.22.424 0 .534l-7.568 3.784a.598.598 0 0 1-.534 0L.165 7.968a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.659z" />
</svg>
<p className="text-center text-xl">
{t("link_preservation_in_queue")}
</p>
<p className="text-center text-lg mt-2">{t("check_back_later")}</p>
</div>
)}
</div>
</>
);
}