/** * Search highlighting utilities for use within ReactMarkdown components. * Recursively processes React children to highlight search term matches * while preserving the markdown-rendered element tree. */ import React from 'react'; import type { SearchMatch } from '@renderer/store/types'; // Highlight styles matching SearchHighlight.tsx const baseStyles: React.CSSProperties = { borderRadius: '0.125rem', padding: '0 0.125rem', }; const currentHighlightStyles: React.CSSProperties = { ...baseStyles, backgroundColor: 'var(--highlight-bg)', color: 'var(--highlight-text)', boxShadow: '0 0 0 1px var(--highlight-ring)', }; const inactiveHighlightStyles: React.CSSProperties = { ...baseStyles, backgroundColor: 'var(--highlight-bg-inactive)', color: 'var(--highlight-text-inactive)', }; export interface SearchContext { itemId: string; query: string; lowerQuery: string; /** Mutable counter tracking match index within the item, incremented as text nodes are processed */ matchCounter: { current: number }; isCurrentItem: boolean; currentMatchIndexInItem: number | null; /** When true, render all matches using the "current" highlight style */ forceAllActive?: boolean; } /** * Create a SearchContext from store state. * Returns null if no search is active. */ export function createSearchContext( searchQuery: string, itemId: string, searchMatches: SearchMatch[], currentSearchIndex: number ): SearchContext | null { if (!searchQuery || searchQuery.trim().length === 0) return null; const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null; const isCurrentItem = currentMatch?.itemId === itemId; return { itemId, query: searchQuery, lowerQuery: searchQuery.toLowerCase(), matchCounter: { current: 0 }, isCurrentItem, currentMatchIndexInItem: isCurrentItem ? (currentMatch?.matchIndexInItem ?? null) : null, }; } /** * Highlight search term matches in a text string. * Increments matchCounter for each match found. */ // eslint-disable-next-line sonarjs/function-return-type -- mixed text/element return function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode { const lowerText = text.toLowerCase(); const parts: React.ReactNode[] = []; let lastIndex = 0; let pos = 0; while ((pos = lowerText.indexOf(ctx.lowerQuery, pos)) !== -1) { if (pos > lastIndex) { parts.push(text.slice(lastIndex, pos)); } const isCurrentResult = ctx.forceAllActive === true || (ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current); parts.push( React.createElement( 'mark', { key: `s-${pos}-${ctx.matchCounter.current}`, style: isCurrentResult ? currentHighlightStyles : inactiveHighlightStyles, 'data-search-result': isCurrentResult ? 'current' : 'match', 'data-search-item-id': ctx.itemId, 'data-search-match-index': ctx.matchCounter.current, }, text.slice(pos, pos + ctx.query.length) ) ); lastIndex = pos + ctx.query.length; pos = lastIndex; ctx.matchCounter.current++; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } if (parts.length === 0) return text; if (parts.length === 1) return parts[0]; return parts; } // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types export function highlightQueryInText( text: string, query: string, itemId: string, options?: { forceAllActive?: boolean } ): React.ReactNode { const ctx = createSearchContext(query, itemId, [], -1); if (!ctx) return text; if (options?.forceAllActive) ctx.forceAllActive = true; return highlightSearchInChildren(text, ctx); } /** * Recursively process React children to highlight search terms in text nodes. * Preserves the React element tree structure (markdown components, etc.) * while adding tags to text content. */ // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types export function highlightSearchInChildren( children: React.ReactNode, ctx: SearchContext ): React.ReactNode { // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types return React.Children.map(children, (child): React.ReactNode => { if (typeof child === 'string') { return highlightSearchText(child, ctx); } if (React.isValidElement<{ children?: React.ReactNode }>(child)) { // Skip elements already created by search highlighting to prevent // double-counting when hl() is applied at multiple markdown component levels // (e.g., both the `strong` and `p` components process the same text) if (child.type === 'mark' && (child.props as Record)['data-search-result']) { return child; } if (child.props.children) { return React.cloneElement( child, undefined, highlightSearchInChildren(child.props.children, ctx) ); } } return child; }); }