@@ -5,8 +5,7 @@ import { FileContent, SearchOptions } from '../types';
55import { marked } from 'marked' ;
66import DOMPurify from 'dompurify' ;
77
8- // hljs is loaded globally via index.html script tag
9- declare const hljs : any ;
8+ // hljs types provided by types/hljs.d.ts
109
1110interface FileCardProps {
1211 file : FileContent ;
@@ -32,91 +31,105 @@ const IconButton: React.FC<{icon: string, title: string, onClick: () => void, di
3231 </ button >
3332) ;
3433
34+ /** Build a regex from search options, returning null if invalid */
35+ function buildSearchRegex ( query : string , options : SearchOptions ) : RegExp | null {
36+ if ( ! query . trim ( ) ) return null ;
37+ const flags = options . caseSensitive ? 'g' : 'gi' ;
38+ let pattern = options . useRegex ? query : query . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
39+ if ( options . wholeWord && ! options . useRegex ) {
40+ pattern = `\\b${ pattern } \\b` ;
41+ }
42+ try {
43+ return new RegExp ( pattern , flags ) ;
44+ } catch {
45+ return null ;
46+ }
47+ }
3548
36- const FileCard : React . FC < FileCardProps > = ( {
37- file, isEditing, onStartEdit, onSaveEdit, onCancelEdit,
49+ const FileCard : React . FC < FileCardProps > = ( {
50+ file, isEditing, onStartEdit, onSaveEdit, onCancelEdit,
3851 isMarkdown, isMarkdownPreview, onToggleMarkdownPreview, onShowToast, fontSize,
3952 searchQuery, searchOptions, activeMatchIndexInFile, onCopyPath
4053} ) => {
4154 const [ editText , setEditText ] = React . useState ( file . content ) ;
4255 const codeRef = React . useRef < HTMLElement > ( null ) ;
43-
56+ const highlightTimerRef = React . useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
57+
4458 React . useEffect ( ( ) => {
4559 setEditText ( file . content ) ;
4660 } , [ file . content ] ) ;
4761
48- // Handle syntax highlighting and search highlighting
62+ // Cleanup highlight timer on unmount
63+ React . useEffect ( ( ) => {
64+ return ( ) => {
65+ if ( highlightTimerRef . current ) clearTimeout ( highlightTimerRef . current ) ;
66+ } ;
67+ } , [ ] ) ;
68+
69+ // Handle syntax highlighting and debounced search highlighting
4970 React . useEffect ( ( ) => {
50- if ( codeRef . current && ! isEditing && ! isMarkdownPreview && ! file . excluded ) {
51- // 1. Set content and syntax highlight
52- codeRef . current . textContent = file . content ;
53- hljs . highlightElement ( codeRef . current ) ;
54-
55- // 2. Apply search highlighting if query exists
56- if ( searchQuery . trim ( ) ) {
57- try {
58- const flags = searchOptions . caseSensitive ? 'g' : 'gi' ;
59- let pattern = searchOptions . useRegex ? searchQuery : searchQuery . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
60- if ( searchOptions . wholeWord && ! searchOptions . useRegex ) {
61- pattern = `\\b${ pattern } \\b` ;
62- }
63- const regex = new RegExp ( pattern , flags ) ;
64-
65- // Use TreeWalker to find text nodes to replace with highlights
66- const walker = document . createTreeWalker ( codeRef . current , NodeFilter . SHOW_TEXT , null ) ;
67- const textNodes : Text [ ] = [ ] ;
68- let node : Node | null ;
69- while ( ( node = walker . nextNode ( ) ) ) {
70- textNodes . push ( node as Text ) ;
71- }
72-
73- let globalMatchIndex = 0 ;
74-
75- textNodes . forEach ( textNode => {
76- const text = textNode . nodeValue ;
77- if ( ! text ) return ;
78-
79- const matches = [ ...text . matchAll ( regex ) ] ;
80- if ( matches . length === 0 ) return ;
81-
82- const fragment = document . createDocumentFragment ( ) ;
83- let lastIndex = 0 ;
84-
85- matches . forEach ( match => {
86- // Text before match
87- if ( match . index ! > lastIndex ) {
88- fragment . appendChild ( document . createTextNode ( text . substring ( lastIndex , match . index ! ) ) ) ;
89- }
90-
91- // The Match
92- const mark = document . createElement ( 'mark' ) ;
93- mark . className = 'search-highlight' ; // Base class
94- // Apply active class if this is the currently selected match
95- if ( globalMatchIndex === activeMatchIndexInFile ) {
96- mark . classList . add ( 'search-highlight-active' ) ;
97- // Scroll active match into view
98- setTimeout ( ( ) => mark . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) , 0 ) ;
99- }
100- mark . textContent = match [ 0 ] ;
101- fragment . appendChild ( mark ) ;
102-
103- globalMatchIndex ++ ;
104- lastIndex = match . index ! + match [ 0 ] . length ;
105- } ) ;
106-
107- // Text after last match
108- if ( lastIndex < text . length ) {
109- fragment . appendChild ( document . createTextNode ( text . substring ( lastIndex ) ) ) ;
110- }
111-
112- textNode . parentNode ?. replaceChild ( fragment , textNode ) ;
113- } ) ;
114-
115- } catch ( e ) {
116- console . debug ( "Search highlight error (regex likely invalid yet):" , e ) ;
117- }
71+ if ( ! codeRef . current || isEditing || isMarkdownPreview || file . excluded ) return ;
72+
73+ // 1. Set content and syntax highlight
74+ codeRef . current . textContent = file . content ;
75+ hljs . highlightElement ( codeRef . current ) ;
76+
77+ // 2. Debounce search highlighting
78+ if ( highlightTimerRef . current ) clearTimeout ( highlightTimerRef . current ) ;
79+
80+ if ( ! searchQuery . trim ( ) ) return ;
81+
82+ const regex = buildSearchRegex ( searchQuery , searchOptions ) ;
83+ if ( ! regex ) return ;
84+
85+ highlightTimerRef . current = setTimeout ( ( ) => {
86+ if ( ! codeRef . current ) return ;
87+
88+ // Use TreeWalker to find text nodes to replace with highlights
89+ const walker = document . createTreeWalker ( codeRef . current , NodeFilter . SHOW_TEXT , null ) ;
90+ const textNodes : Text [ ] = [ ] ;
91+ let node : Node | null ;
92+ while ( ( node = walker . nextNode ( ) ) ) {
93+ textNodes . push ( node as Text ) ;
11894 }
119- }
95+
96+ let globalMatchIndex = 0 ;
97+
98+ textNodes . forEach ( textNode => {
99+ const text = textNode . nodeValue ;
100+ if ( ! text ) return ;
101+
102+ const matches = [ ...text . matchAll ( regex ) ] ;
103+ if ( matches . length === 0 ) return ;
104+
105+ const fragment = document . createDocumentFragment ( ) ;
106+ let lastIndex = 0 ;
107+
108+ matches . forEach ( match => {
109+ if ( match . index ! > lastIndex ) {
110+ fragment . appendChild ( document . createTextNode ( text . substring ( lastIndex , match . index ! ) ) ) ;
111+ }
112+
113+ const mark = document . createElement ( 'mark' ) ;
114+ mark . className = 'search-highlight' ;
115+ if ( globalMatchIndex === activeMatchIndexInFile ) {
116+ mark . classList . add ( 'search-highlight-active' ) ;
117+ setTimeout ( ( ) => mark . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) , 0 ) ;
118+ }
119+ mark . textContent = match [ 0 ] ;
120+ fragment . appendChild ( mark ) ;
121+
122+ globalMatchIndex ++ ;
123+ lastIndex = match . index ! + match [ 0 ] . length ;
124+ } ) ;
125+
126+ if ( lastIndex < text . length ) {
127+ fragment . appendChild ( document . createTextNode ( text . substring ( lastIndex ) ) ) ;
128+ }
129+
130+ textNode . parentNode ?. replaceChild ( fragment , textNode ) ;
131+ } ) ;
132+ } , 150 ) ;
120133 } , [ file , isEditing , isMarkdownPreview , searchQuery , searchOptions , activeMatchIndexInFile ] ) ;
121134
122135
0 commit comments