Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

HighlightRenderer.tsx8.69 kB
'use client'; import React, { useEffect, useCallback } from 'react'; import type { HighlightParams } from '@/lib/highlights/highlightUrl'; interface HighlightRendererProps { /** Container ref to apply highlights to */ containerRef: React.RefObject<HTMLElement>; /** Highlight params from URL */ highlight: HighlightParams | null; /** Callback after highlight is applied */ onHighlightApplied?: () => void; /** Callback if highlight fails to apply */ onHighlightFailed?: (reason: string) => void; /** Custom highlight class */ highlightClass?: string; } /** * Default highlight styles */ const DEFAULT_HIGHLIGHT_CLASS = ` bg-yellow-200 dark:bg-yellow-800/50 border-b-2 border-yellow-400 dark:border-yellow-600 rounded-sm px-0.5 transition-colors duration-300 `; /** * Find section element by ID */ function findSectionElement( container: HTMLElement, sectionId: string ): HTMLElement | null { // Try data-section-id attribute first let element = container.querySelector<HTMLElement>( `[data-section-id="${sectionId}"]` ); // Fallback to id attribute if (!element) { element = container.querySelector<HTMLElement>(`#${CSS.escape(sectionId)}`); } return element; } /** * Get text content and create a mapping of text positions to DOM nodes */ interface TextNodeMapping { node: Text; start: number; end: number; } function getTextNodeMappings(element: HTMLElement): TextNodeMapping[] { const mappings: TextNodeMapping[] = []; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null ); let offset = 0; let node: Node | null; while ((node = walker.nextNode())) { const textNode = node as Text; const length = textNode.textContent?.length || 0; if (length > 0) { mappings.push({ node: textNode, start: offset, end: offset + length, }); offset += length; } } return mappings; } /** * Apply highlight to text range using Range API */ function applyHighlight( mappings: TextNodeMapping[], startOffset: number, endOffset: number, highlightClass: string ): HTMLElement | null { // Find start and end nodes let startNode: Text | null = null; let startNodeOffset = 0; let endNode: Text | null = null; let endNodeOffset = 0; for (const mapping of mappings) { if (!startNode && startOffset >= mapping.start && startOffset < mapping.end) { startNode = mapping.node; startNodeOffset = startOffset - mapping.start; } if (endOffset > mapping.start && endOffset <= mapping.end) { endNode = mapping.node; endNodeOffset = endOffset - mapping.start; } } if (!startNode || !endNode) { return null; } try { // Create a range const range = document.createRange(); range.setStart(startNode, startNodeOffset); range.setEnd(endNode, endNodeOffset); // Create highlight wrapper const highlightSpan = document.createElement('mark'); highlightSpan.className = highlightClass; highlightSpan.setAttribute('data-highlight', 'true'); // Wrap the range contents range.surroundContents(highlightSpan); return highlightSpan; } catch (err) { // surroundContents fails if range crosses element boundaries // In that case, we need a more complex approach console.warn('Could not apply highlight with surroundContents:', err); // Try highlighting each text node individually return applyHighlightAcrossNodes( mappings, startOffset, endOffset, highlightClass ); } } /** * Apply highlight across multiple text nodes (handles cross-element selections) */ function applyHighlightAcrossNodes( mappings: TextNodeMapping[], startOffset: number, endOffset: number, highlightClass: string ): HTMLElement | null { let firstHighlight: HTMLElement | null = null; for (const mapping of mappings) { // Skip nodes outside the range if (mapping.end <= startOffset || mapping.start >= endOffset) { continue; } // Calculate the portion of this node to highlight const highlightStart = Math.max(0, startOffset - mapping.start); const highlightEnd = Math.min( mapping.node.textContent?.length || 0, endOffset - mapping.start ); if (highlightEnd <= highlightStart) continue; try { const range = document.createRange(); range.setStart(mapping.node, highlightStart); range.setEnd(mapping.node, highlightEnd); const highlightSpan = document.createElement('mark'); highlightSpan.className = highlightClass; highlightSpan.setAttribute('data-highlight', 'true'); range.surroundContents(highlightSpan); if (!firstHighlight) { firstHighlight = highlightSpan; } } catch (err) { console.warn('Could not highlight text node:', err); } } return firstHighlight; } /** * Remove existing highlights from container */ function clearHighlights(container: HTMLElement): void { const highlights = container.querySelectorAll('[data-highlight="true"]'); highlights.forEach((highlight) => { const parent = highlight.parentNode; if (parent) { while (highlight.firstChild) { parent.insertBefore(highlight.firstChild, highlight); } parent.removeChild(highlight); } }); // Normalize text nodes (merge adjacent ones) container.normalize(); } /** * HighlightRenderer - Applies URL-based highlights to bill text * * Features: * - Reads highlight params from URL * - Applies visual highlighting to specified text range * - Scrolls highlighted text into view * - Cleans up existing highlights before applying new ones * - Handles cross-element selections */ export const HighlightRenderer: React.FC<HighlightRendererProps> = ({ containerRef, highlight, onHighlightApplied, onHighlightFailed, highlightClass = DEFAULT_HIGHLIGHT_CLASS, }) => { const applyHighlightFromParams = useCallback(() => { if (!highlight || !containerRef.current) { return; } const container = containerRef.current; // Clear any existing highlights first clearHighlights(container); // Find the section element const sectionElement = findSectionElement(container, highlight.section); if (!sectionElement) { onHighlightFailed?.(`Section "${highlight.section}" not found`); return; } // Get text node mappings for the section const mappings = getTextNodeMappings(sectionElement); const totalLength = mappings.reduce( (sum, m) => sum + (m.end - m.start), 0 ); // Validate highlight range if (highlight.start < 0 || highlight.end > totalLength) { onHighlightFailed?.( `Invalid highlight range: ${highlight.start}-${highlight.end} (section length: ${totalLength})` ); return; } // Apply the highlight const highlightElement = applyHighlight( mappings, highlight.start, highlight.end, highlightClass.trim() ); if (highlightElement) { // Scroll highlight into view with some padding setTimeout(() => { highlightElement.scrollIntoView({ behavior: 'smooth', block: 'center', }); }, 100); onHighlightApplied?.(); } else { onHighlightFailed?.('Failed to apply highlight'); } }, [highlight, containerRef, highlightClass, onHighlightApplied, onHighlightFailed]); // Apply highlight when params change or container is ready useEffect(() => { if (!highlight) return; // Wait for container to be populated const observer = new MutationObserver(() => { if (containerRef.current?.querySelector(`#${CSS.escape(highlight.section)}`)) { observer.disconnect(); applyHighlightFromParams(); } }); if (containerRef.current) { // Try applying immediately const sectionElement = findSectionElement(containerRef.current, highlight.section); if (sectionElement) { applyHighlightFromParams(); } else { // Watch for changes observer.observe(containerRef.current, { childList: true, subtree: true, }); } } return () => { observer.disconnect(); }; }, [highlight, containerRef, applyHighlightFromParams]); // Cleanup highlights when component unmounts or highlight changes useEffect(() => { return () => { if (containerRef.current) { clearHighlights(containerRef.current); } }; }, [containerRef, highlight]); // This component doesn't render anything visible return null; }; export default HighlightRenderer;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server