1- import { useEffect } from "react" ;
1+ import { useEffect , useRef } from "react" ;
22import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" ;
33
4+ const ACTIVE_HIGHLIGHT_NAME = "comet-search-active" ;
45const HIGHLIGHT_NAME = "comet-search" ;
56const HIGHLIGHT_STYLE_ID = "comet-search-highlight-style" ;
67const HIGHLIGHT_STYLES = `
78::highlight(${ HIGHLIGHT_NAME } ) {
9+ background-color: var(--editor-selection);
10+ color: var(--editor-text);
11+ }
12+
13+ ::highlight(${ ACTIVE_HIGHLIGHT_NAME } ) {
814 background-color: rgb(253 224 71);
915 color: var(--background);
1016}
1117` ;
1218
13- function clearHighlight ( ) {
14- ( CSS as CSSWithHighlights ) . highlights ?. delete ( HIGHLIGHT_NAME ) ;
19+ type CSSWithHighlights = typeof CSS & {
20+ highlights ?: Map < string , Highlight > ;
21+ } ;
22+
23+ function clearHighlights ( ) {
24+ const highlights = ( CSS as CSSWithHighlights ) . highlights ;
25+ highlights ?. delete ( HIGHLIGHT_NAME ) ;
26+ highlights ?. delete ( ACTIVE_HIGHLIGHT_NAME ) ;
1527}
1628
1729function getOrCreateStyleElement ( ) {
@@ -25,25 +37,30 @@ function getOrCreateStyleElement() {
2537}
2638
2739export default function SearchHighlightPlugin ( {
40+ activeMatchIndex = null ,
41+ highlightAllMatchesYellow = false ,
42+ onMatchCountChange,
2843 searchWords,
2944} : {
45+ activeMatchIndex ?: number | null ;
46+ highlightAllMatchesYellow ?: boolean ;
47+ onMatchCountChange ?( count : number ) : void ;
3048 searchWords : string [ ] ;
3149} ) {
3250 const [ editor ] = useLexicalComposerContext ( ) ;
51+ const shouldScrollRef = useRef ( true ) ;
3352
34- // Toggle highlight visibility by adding/removing the CSS rule.
35- // Highlight ranges stay registered — they're just invisible without the rule.
3653 useEffect ( ( ) => {
3754 return editor . registerRootListener ( ( root , prevRoot ) => {
3855 if ( prevRoot ) {
3956 prevRoot . removeEventListener ( "focusin" , handleFocusIn ) ;
4057 prevRoot . removeEventListener ( "focusout" , handleFocusOut ) ;
4158 }
59+
4260 if ( root ) {
4361 root . addEventListener ( "focusin" , handleFocusIn ) ;
4462 root . addEventListener ( "focusout" , handleFocusOut ) ;
4563
46- // Sync to current state
4764 if ( root . contains ( document . activeElement ) ) {
4865 handleFocusIn ( ) ;
4966 } else {
@@ -61,62 +78,136 @@ export default function SearchHighlightPlugin({
6178 }
6279 } , [ editor ] ) ;
6380
64- // Maintain highlight ranges — always applied regardless of focus.
6581 useEffect ( ( ) => {
6682 const highlights = ( CSS as CSSWithHighlights ) . highlights ;
67- if ( ! highlights ) return ;
83+ if ( ! highlights ) {
84+ onMatchCountChange ?.( 0 ) ;
85+ return ;
86+ }
6887
69- getOrCreateStyleElement ( ) . textContent = HIGHLIGHT_STYLES ;
88+ shouldScrollRef . current = true ;
89+ const root = editor . getRootElement ( ) ;
90+ getOrCreateStyleElement ( ) . textContent =
91+ root ?. contains ( document . activeElement ) ? "" : HIGHLIGHT_STYLES ;
7092
7193 if ( searchWords . length === 0 ) {
72- highlights . delete ( HIGHLIGHT_NAME ) ;
94+ clearHighlights ( ) ;
95+ onMatchCountChange ?.( 0 ) ;
7396 return ;
7497 }
7598
7699 const apply = ( ) => {
77- const root = editor . getRootElement ( ) ;
78- if ( ! root ) {
79- highlights . delete ( HIGHLIGHT_NAME ) ;
100+ const currentRoot = editor . getRootElement ( ) ;
101+ if ( ! currentRoot ) {
102+ clearHighlights ( ) ;
103+ onMatchCountChange ?.( 0 ) ;
80104 return ;
81105 }
82106
83- const escaped = searchWords . map ( ( w ) =>
84- w . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ,
107+ const escaped = searchWords . map ( ( word ) =>
108+ word . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ,
85109 ) ;
86110 const regex = new RegExp ( `(${ escaped . join ( "|" ) } )` , "gi" ) ;
87111 const ranges : Range [ ] = [ ] ;
88- const walker = document . createTreeWalker ( root , NodeFilter . SHOW_TEXT ) ;
112+ const walker = document . createTreeWalker (
113+ currentRoot ,
114+ NodeFilter . SHOW_TEXT ,
115+ ) ;
89116
90117 let textNode : Text | null ;
91118 while ( ( textNode = walker . nextNode ( ) as Text | null ) ) {
92119 const text = textNode . textContent || "" ;
93120 let match : RegExpExecArray | null ;
94121 while ( ( match = regex . exec ( text ) ) !== null ) {
95- const range = new Range ( ) ;
122+ const range = document . createRange ( ) ;
96123 range . setStart ( textNode , match . index ) ;
97124 range . setEnd ( textNode , match . index + match [ 0 ] . length ) ;
98125 ranges . push ( range ) ;
99126 }
100127 }
101128
102- if ( ranges . length > 0 ) {
103- highlights . set ( HIGHLIGHT_NAME , new Highlight ( ...ranges ) ) ;
104- } else {
129+ onMatchCountChange ?.( ranges . length ) ;
130+ if ( ranges . length === 0 ) {
131+ clearHighlights ( ) ;
132+ return ;
133+ }
134+
135+ if ( highlightAllMatchesYellow ) {
105136 highlights . delete ( HIGHLIGHT_NAME ) ;
137+ highlights . set ( ACTIVE_HIGHLIGHT_NAME , new Highlight ( ...ranges ) ) ;
138+ } else {
139+ const hasActiveMatch =
140+ activeMatchIndex !== null &&
141+ activeMatchIndex >= 0 &&
142+ activeMatchIndex < ranges . length ;
143+ const inactiveRanges = hasActiveMatch
144+ ? ranges . filter ( ( _ , index ) => index !== activeMatchIndex )
145+ : ranges ;
146+
147+ if ( inactiveRanges . length > 0 ) {
148+ highlights . set ( HIGHLIGHT_NAME , new Highlight ( ...inactiveRanges ) ) ;
149+ } else {
150+ highlights . delete ( HIGHLIGHT_NAME ) ;
151+ }
152+
153+ if ( hasActiveMatch ) {
154+ highlights . set (
155+ ACTIVE_HIGHLIGHT_NAME ,
156+ new Highlight ( ranges [ activeMatchIndex ] ) ,
157+ ) ;
158+ } else {
159+ highlights . delete ( ACTIVE_HIGHLIGHT_NAME ) ;
160+ }
161+ }
162+
163+ if ( ! shouldScrollRef . current ) {
164+ return ;
165+ }
166+
167+ shouldScrollRef . current = false ;
168+ const hasActiveMatch =
169+ ! highlightAllMatchesYellow &&
170+ activeMatchIndex !== null &&
171+ activeMatchIndex >= 0 &&
172+ activeMatchIndex < ranges . length ;
173+ const targetRange = hasActiveMatch ? ranges [ activeMatchIndex ] : ranges [ 0 ] ;
174+ const scrollContainer = currentRoot . closest (
175+ "[data-editor-scroll-container]" ,
176+ ) ;
177+ if ( ! scrollContainer ) {
178+ return ;
106179 }
180+
181+ const scrollRect = scrollContainer . getBoundingClientRect ( ) ;
182+ const targetRect = targetRange . getBoundingClientRect ( ) ;
183+ const hasVisibleMatch =
184+ targetRect . bottom > scrollRect . top && targetRect . top < scrollRect . bottom ;
185+
186+ if ( hasVisibleMatch ) {
187+ return ;
188+ }
189+
190+ const scrollTop =
191+ scrollContainer . scrollTop +
192+ targetRect . top -
193+ scrollRect . top -
194+ scrollRect . height / 3 ;
195+ scrollContainer . scrollTo ( { top : scrollTop , behavior : "instant" } ) ;
107196 } ;
108197
109198 apply ( ) ;
110199 return editor . registerUpdateListener ( ( ) => {
111200 apply ( ) ;
112201 } ) ;
113- } , [ editor , searchWords ] ) ;
202+ } , [
203+ activeMatchIndex ,
204+ editor ,
205+ highlightAllMatchesYellow ,
206+ onMatchCountChange ,
207+ searchWords ,
208+ ] ) ;
114209
115- useEffect ( ( ) => clearHighlight , [ ] ) ;
210+ useEffect ( ( ) => clearHighlights , [ ] ) ;
116211
117212 return null ;
118213}
119-
120- type CSSWithHighlights = typeof CSS & {
121- highlights ?: Map < string , Highlight > ;
122- } ;
0 commit comments