- Fix React hooks violations: ref updates during render (useDraftPersistence, useChipDraftPersistence, useAttachments), setState in effects across 15+ components, useCallback self-reference TDZ in useResizableColumns - Fix TypeScript lint: remove unnecessary type assertions, replace inline import() annotations with direct imports, remove unused variables/imports - Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver, no-misleading-array-reverse in TeamProvisioningService, use-type-alias in ClaudeLogsSection, variable shadowing in ChangeExtractorService - Fix accessibility: associate labels with controls in filter popovers - Fix template expression safety: wrap unknown errors with String() - Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity to match filesystem birthtimeMs resolution on Linux - Replace TODO comments with NOTE where features are intentionally disabled - Remove unused leadContextByTeam from TeamDetailView store selector 62 files changed across main process, renderer, shared types, and hooks. All 1646 tests pass, typecheck clean, 0 lint errors.
163 lines
5.1 KiB
TypeScript
163 lines
5.1 KiB
TypeScript
/**
|
|
* 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 <mark> 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 <mark> 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<string, unknown>)['data-search-result']) {
|
|
return child;
|
|
}
|
|
|
|
if (child.props.children) {
|
|
return React.cloneElement(
|
|
child,
|
|
undefined,
|
|
highlightSearchInChildren(child.props.children, ctx)
|
|
);
|
|
}
|
|
}
|
|
|
|
return child;
|
|
});
|
|
}
|