From 86d74e3f32dd5bccc8df195b55391e206df9a1c4 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Fri, 27 Dec 2024 16:09:29 +0000 Subject: feat: Implement highlights support for links. Fixes #620 --- .../dashboard/preview/BookmarkHtmlHighlighter.tsx | 322 +++++++++++++++++++++ .../dashboard/preview/BookmarkPreview.tsx | 2 + .../components/dashboard/preview/HighlightsBox.tsx | 97 +++++++ .../dashboard/preview/LinkContentSection.tsx | 94 +++++- .../web/components/dashboard/preview/highlights.ts | 15 + 5 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx create mode 100644 apps/web/components/dashboard/preview/HighlightsBox.tsx create mode 100644 apps/web/components/dashboard/preview/highlights.ts (limited to 'apps/web/components') diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx new file mode 100644 index 00000000..b99a1a56 --- /dev/null +++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx @@ -0,0 +1,322 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { PopoverAnchor } from "@radix-ui/react-popover"; +import { Check, Trash2 } from "lucide-react"; + +import { + SUPPORTED_HIGHLIGHT_COLORS, + ZHighlightColor, +} from "@hoarder/shared/types/highlights"; + +import { HIGHLIGHT_COLOR_MAP } from "./highlights"; + +interface ColorPickerMenuProps { + position: { x: number; y: number } | null; + onColorSelect: (color: ZHighlightColor) => void; + onDelete?: () => void; + selectedHighlight: Highlight | null; + onClose: () => void; +} + +const ColorPickerMenu: React.FC = ({ + position, + onColorSelect, + onDelete, + selectedHighlight, + onClose, +}) => { + return ( + { + if (!val) { + onClose(); + } + }} + > + + + {SUPPORTED_HIGHLIGHT_COLORS.map((color) => ( + + ))} + {selectedHighlight && ( + + )} + + + ); +}; + +export interface Highlight { + id: string; + startOffset: number; + endOffset: number; + color: ZHighlightColor; + text: string | null; +} + +interface HTMLHighlighterProps { + htmlContent: string; + className?: string; + highlights?: Highlight[]; + onHighlight?: (highlight: Highlight) => void; + onUpdateHighlight?: (highlight: Highlight) => void; + onDeleteHighlight?: (highlight: Highlight) => void; +} + +function BookmarkHTMLHighlighter({ + htmlContent, + className, + highlights = [], + onHighlight, + onUpdateHighlight, + onDeleteHighlight, +}: HTMLHighlighterProps) { + const contentRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [pendingHighlight, setPendingHighlight] = useState( + null, + ); + const [selectedHighlight, setSelectedHighlight] = useState( + null, + ); + + // Apply existing highlights when component mounts or highlights change + useEffect(() => { + if (!contentRef.current) return; + + // Clear existing highlights first + const existingHighlights = contentRef.current.querySelectorAll( + "span[data-highlight]", + ); + existingHighlights.forEach((el) => { + const parent = el.parentNode; + if (parent) { + while (el.firstChild) { + parent.insertBefore(el.firstChild, el); + } + parent.removeChild(el); + } + }); + + // Apply all highlights + highlights.forEach((highlight) => { + applyHighlightByOffset(highlight); + }); + if (pendingHighlight) { + applyHighlightByOffset(pendingHighlight); + } + }); + + const handleMouseUp = (e: React.MouseEvent) => { + const selection = window.getSelection(); + + // Check if we clicked on an existing highlight + const target = e.target as HTMLElement; + if (target.dataset.highlight) { + const highlightId = target.dataset.highlightId; + if (highlightId && highlights) { + const highlight = highlights.find((h) => h.id === highlightId); + if (!highlight) { + return; + } + setSelectedHighlight(highlight); + setMenuPosition({ + x: e.clientX, + y: e.clientY, + }); + return; + } + } + + if (!selection || selection.isCollapsed || !contentRef.current) { + return; + } + + const range = selection.getRangeAt(0); + + // Only process selections within our component + if (!contentRef.current.contains(range.commonAncestorContainer)) { + return; + } + + // Position the menu above the selection + const rect = range.getBoundingClientRect(); + setMenuPosition({ + x: rect.left + rect.width / 2, // Center the menu + y: rect.top, + }); + + // Store the highlight for later use + setPendingHighlight(createHighlightFromRange(range, "yellow")); + }; + + const handleColorSelect = (color: ZHighlightColor) => { + if (pendingHighlight) { + pendingHighlight.color = color; + onHighlight?.(pendingHighlight); + } else if (selectedHighlight) { + selectedHighlight.color = color; + onUpdateHighlight?.(selectedHighlight); + } + closeColorPicker(); + }; + + const closeColorPicker = () => { + setMenuPosition(null); + setPendingHighlight(null); + setSelectedHighlight(null); + window.getSelection()?.removeAllRanges(); + }; + + const handleDelete = () => { + if (selectedHighlight && onDeleteHighlight) { + onDeleteHighlight(selectedHighlight); + closeColorPicker(); + } + }; + + const getTextNodeOffset = (node: Node): number => { + let offset = 0; + const walker = document.createTreeWalker( + contentRef.current!, + NodeFilter.SHOW_TEXT, + null, + ); + + while (walker.nextNode()) { + if (walker.currentNode === node) { + return offset; + } + offset += walker.currentNode.textContent?.length ?? 0; + } + return -1; + }; + + const createHighlightFromRange = ( + range: Range, + color: ZHighlightColor, + ): Highlight | null => { + if (!contentRef.current) return null; + + const startOffset = + getTextNodeOffset(range.startContainer) + range.startOffset; + const endOffset = getTextNodeOffset(range.endContainer) + range.endOffset; + + if (startOffset === -1 || endOffset === -1) return null; + + const highlight: Highlight = { + id: "NOT_SET", + startOffset, + endOffset, + color, + text: range.toString(), + }; + + applyHighlightByOffset(highlight); + return highlight; + }; + + const applyHighlightByOffset = (highlight: Highlight) => { + if (!contentRef.current) return; + + let currentOffset = 0; + const walker = document.createTreeWalker( + contentRef.current, + NodeFilter.SHOW_TEXT, + null, + ); + + const ranges: { node: Text; start: number; end: number }[] = []; + + // Find all text nodes that need highlighting + let node: Text | null; + while ((node = walker.nextNode() as Text)) { + const nodeLength = node.length; + const nodeStart = currentOffset; + const nodeEnd = nodeStart + nodeLength; + + if (nodeStart < highlight.endOffset && nodeEnd > highlight.startOffset) { + ranges.push({ + node, + start: Math.max(0, highlight.startOffset - nodeStart), + end: Math.min(nodeLength, highlight.endOffset - nodeStart), + }); + } + + currentOffset += nodeLength; + } + + // Apply highlights to found ranges + ranges.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 span = document.createElement("span"); + span.classList.add(HIGHLIGHT_COLOR_MAP.bg[highlight.color]); + span.classList.add("text-gray-600"); + span.dataset.highlight = "true"; + span.dataset.highlightId = highlight.id; + node.parentNode?.insertBefore(span, node); + span.appendChild(node); + }); + }; + + return ( +
+
+ +
+ ); +} + +export default BookmarkHTMLHighlighter; diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index ff6330fa..b854146c 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -30,6 +30,7 @@ import ActionBar from "./ActionBar"; import { AssetContentSection } from "./AssetContentSection"; import AttachmentBox from "./AttachmentBox"; import { EditableTitle } from "./EditableTitle"; +import HighlightsBox from "./HighlightsBox"; import LinkContentSection from "./LinkContentSection"; import { NoteEditor } from "./NoteEditor"; import { TextContentSection } from "./TextContentSection"; @@ -150,6 +151,7 @@ export default function BookmarkPreview({
+ diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx new file mode 100644 index 00000000..3c873f3d --- /dev/null +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -0,0 +1,97 @@ +import { ActionButton } from "@/components/ui/action-button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { Separator } from "@radix-ui/react-dropdown-menu"; +import { ChevronsDownUp, Trash2 } from "lucide-react"; + +import { useDeleteHighlight } from "@hoarder/shared-react/hooks/highlights"; +import { ZHighlight } from "@hoarder/shared/types/highlights"; + +import { HIGHLIGHT_COLOR_MAP } from "./highlights"; + +function HighlightCard({ highlight }: { highlight: ZHighlight }) { + const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight( + { + onSuccess: () => { + toast({ + description: "Highlight has been deleted!", + }); + }, + onError: () => { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + }, + }, + ); + + const onBookmarkClick = () => { + document + .querySelector(`[data-highlight-id="${highlight.id}"]`) + ?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }; + return ( +
+
+ +
+
+ deleteHighlight({ highlightId: highlight.id })} + > + + +
+
+ ); +} + +export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) { + const { t } = useTranslation(); + + const { data: highlights, isPending: isLoading } = + api.highlights.getForBookmark.useQuery({ bookmarkId }); + + if (isLoading || !highlights || highlights?.highlights.length === 0) { + return null; + } + + return ( + + + {t("common.highlights")} + + + + {highlights.highlights.map((highlight) => ( + <> + + + + ))} + + + ); +} diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 320fc561..f09cc31f 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import Image from "next/image"; +import BookmarkHTMLHighlighter from "@/components/dashboard/preview/BookmarkHtmlHighlighter"; import { Select, SelectContent, @@ -8,9 +9,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; import { ScrollArea } from "@radix-ui/react-scroll-area"; +import { + useCreateHighlight, + useDeleteHighlight, + useUpdateHighlight, +} from "@hoarder/shared-react/hooks/highlights"; import { BookmarkTypes, ZBookmark, @@ -42,7 +50,59 @@ function ScreenshotSection({ link }: { link: ZBookmarkedLink }) { ); } -function CachedContentSection({ link }: { link: ZBookmarkedLink }) { +function CachedContentSection({ + bookmarkId, + link, +}: { + bookmarkId: string; + link: ZBookmarkedLink; +}) { + const { data } = api.highlights.getForBookmark.useQuery({ + bookmarkId, + }); + + const { mutate: createHighlight } = useCreateHighlight({ + onSuccess: () => { + toast({ + description: "Highlight has been created!", + }); + }, + onError: () => { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + }, + }); + + const { mutate: updateHighlight } = useUpdateHighlight({ + onSuccess: () => { + toast({ + description: "Highlight has been updated!", + }); + }, + onError: () => { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + }, + }); + + const { mutate: deleteHighlight } = useDeleteHighlight({ + onSuccess: () => { + toast({ + description: "Highlight has been deleted!", + }); + }, + onError: () => { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + }, + }); + let content; if (!link.htmlContent) { content = ( @@ -50,11 +110,31 @@ function CachedContentSection({ link }: { link: ZBookmarkedLink }) { ); } else { content = ( -
+ deleteHighlight({ + highlightId: h.id, + }) + } + onUpdateHighlight={(h) => + updateHighlight({ + highlightId: h.id, + color: h.color, + }) + } + onHighlight={(h) => + createHighlight({ + startOffset: h.startOffset, + endOffset: h.endOffset, + color: h.color, + bookmarkId, + text: h.text, + note: null, + }) + } /> ); } @@ -89,7 +169,9 @@ export default function LinkContentSection({ let content; if (section === "cached") { - content = ; + content = ( + + ); } else if (section === "archive") { content = ; } else if (section === "video") { diff --git a/apps/web/components/dashboard/preview/highlights.ts b/apps/web/components/dashboard/preview/highlights.ts new file mode 100644 index 00000000..e871303e --- /dev/null +++ b/apps/web/components/dashboard/preview/highlights.ts @@ -0,0 +1,15 @@ +// Tailwind requires the color to be complete strings (can't be dynamic), so we have to list all the strings here manually. +export const HIGHLIGHT_COLOR_MAP = { + bg: { + red: "bg-red-200", + green: "bg-green-200", + blue: "bg-blue-200", + yellow: "bg-yellow-200", + } as const, + ["border-l"]: { + red: "border-l-red-200", + green: "border-l-green-200", + blue: "border-l-blue-200", + yellow: "border-l-yellow-200", + } as const, +}; -- cgit v1.2.3-70-g09d2