import React, { useEffect, useRef, useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; 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;