diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-12-27 16:09:29 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2024-12-27 16:09:29 +0000 |
| commit | 86d74e3f32dd5bccc8df195b55391e206df9a1c4 (patch) | |
| tree | 0db92b5139f9ef0c8909c16db72fbef782b770b6 /apps/web/components | |
| parent | a23044bb74e01c861a92417c00d293ff86384e83 (diff) | |
| download | karakeep-86d74e3f32dd5bccc8df195b55391e206df9a1c4.tar.zst | |
feat: Implement highlights support for links. Fixes #620
Diffstat (limited to 'apps/web/components')
5 files changed, 524 insertions, 6 deletions
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<ColorPickerMenuProps> = ({ + position, + onColorSelect, + onDelete, + selectedHighlight, + onClose, +}) => { + return ( + <Popover + open={position !== null} + onOpenChange={(val) => { + if (!val) { + onClose(); + } + }} + > + <PopoverAnchor + className="fixed" + style={{ + left: position?.x, + top: position?.y, + }} + /> + <PopoverContent side="top" className="flex w-fit items-center gap-1 p-2"> + {SUPPORTED_HIGHLIGHT_COLORS.map((color) => ( + <Button + size="none" + key={color} + onClick={() => onColorSelect(color)} + variant="none" + className={cn( + `size-8 rounded-full hover:border focus-visible:ring-0`, + HIGHLIGHT_COLOR_MAP.bg[color], + )} + > + {selectedHighlight?.color === color && ( + <Check className="size-5 text-gray-600" /> + )} + </Button> + ))} + {selectedHighlight && ( + <Button + size="none" + className="size-8 rounded-full" + onClick={onDelete} + variant="ghost" + > + <Trash2 className="size-5 text-destructive" /> + </Button> + )} + </PopoverContent> + </Popover> + ); +}; + +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<HTMLDivElement>(null); + const [menuPosition, setMenuPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [pendingHighlight, setPendingHighlight] = useState<Highlight | null>( + null, + ); + const [selectedHighlight, setSelectedHighlight] = useState<Highlight | null>( + 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 ( + <div> + <div + role="presentation" + ref={contentRef} + dangerouslySetInnerHTML={{ __html: htmlContent }} + onMouseUp={handleMouseUp} + className={className} + /> + <ColorPickerMenu + position={menuPosition} + onColorSelect={handleColorSelect} + onDelete={handleDelete} + selectedHighlight={selectedHighlight} + onClose={closeColorPicker} + /> + </div> + ); +} + +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({ <NoteEditor bookmark={bookmark} /> </div> <AttachmentBox bookmark={bookmark} /> + <HighlightsBox bookmarkId={bookmark.id} /> <ActionBar bookmark={bookmark} /> </div> </div> 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 ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button onClick={onBookmarkClick}> + <blockquote + className={cn( + "prose border-l-[6px] p-2 pl-6 italic dark:prose-invert prose-p:text-sm", + HIGHLIGHT_COLOR_MAP["border-l"][highlight.color], + )} + > + <p>{highlight.text}</p> + </blockquote> + </button> + </div> + <div className="flex gap-2"> + <ActionButton + loading={isDeleting} + variant="ghost" + onClick={() => deleteHighlight({ highlightId: highlight.id })} + > + <Trash2 className="size-4 text-destructive" /> + </ActionButton> + </div> + </div> + ); +} + +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 ( + <Collapsible defaultOpen={true}> + <CollapsibleTrigger className="flex w-full items-center justify-between gap-2 text-sm text-gray-400"> + {t("common.highlights")} + <ChevronsDownUp className="size-4" /> + </CollapsibleTrigger> + <CollapsibleContent className="group flex flex-col py-3 text-sm"> + {highlights.highlights.map((highlight) => ( + <> + <HighlightCard key={highlight.id} highlight={highlight} /> + <Separator className="m-2 h-0.5 bg-gray-200 last:hidden" /> + </> + ))} + </CollapsibleContent> + </Collapsible> + ); +} 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,10 +9,17 @@ 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, ZBookmarkedLink, @@ -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 = ( - <div - dangerouslySetInnerHTML={{ - __html: link.htmlContent || "", - }} + <BookmarkHTMLHighlighter + htmlContent={link.htmlContent || ""} className="prose mx-auto dark:prose-invert" + highlights={data?.highlights ?? []} + onDeleteHighlight={(h) => + 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 = <CachedContentSection link={bookmark.content} />; + content = ( + <CachedContentSection bookmarkId={bookmark.id} link={bookmark.content} /> + ); } else if (section === "archive") { content = <FullPageArchiveSection link={bookmark.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, +}; |
