11"use client" ;
22
33import { useSearchParams } from "next/navigation" ;
4- import { Suspense , useEffect , useState } from "react" ;
4+ import { Suspense , useCallback , useDeferredValue , useEffect , useRef , useState } from "react" ;
55import type { DiffResult } from "../src/core" ;
66import { DiffViewer } from "../src/react" ;
77import type { UNDocumentMetadata } from "../src/un-fetcher" ;
@@ -25,6 +25,45 @@ function HomeContent() {
2525 const [ loading , setLoading ] = useState ( false ) ;
2626 const [ error , setError ] = useState < string | null > ( null ) ;
2727 const [ copied , setCopied ] = useState ( false ) ;
28+ const [ searchInput , setSearchInput ] = useState ( "" ) ;
29+ const [ currentMatchIndex , setCurrentMatchIndex ] = useState ( 0 ) ;
30+ const [ matchCount , setMatchCount ] = useState ( 0 ) ;
31+ const searchQuery = useDeferredValue ( searchInput ) ;
32+ const diffContainerRef = useRef < HTMLDivElement > ( null ) ;
33+
34+ // Count matches and reset index whenever the deferred query changes
35+ useEffect ( ( ) => {
36+ const container = diffContainerRef . current ;
37+ if ( ! container ) return ;
38+ // RAF ensures the DOM has been updated after the deferred re-render
39+ const id = requestAnimationFrame ( ( ) => {
40+ const marks = container . querySelectorAll < HTMLElement > ( ".search-highlight" ) ;
41+ setMatchCount ( marks . length ) ;
42+ setCurrentMatchIndex ( marks . length > 0 ? 0 : - 1 ) ;
43+ if ( marks . length > 0 ) {
44+ marks [ 0 ] . scrollIntoView ( { behavior : "smooth" , block : "center" } ) ;
45+ marks [ 0 ] . style . outline = "2px solid #009edb" ;
46+ }
47+ } ) ;
48+ return ( ) => cancelAnimationFrame ( id ) ;
49+ } , [ searchQuery ] ) ;
50+
51+ const navigateMatch = useCallback (
52+ ( dir : 1 | - 1 ) => {
53+ const container = diffContainerRef . current ;
54+ if ( ! container ) return ;
55+ const marks =
56+ container . querySelectorAll < HTMLElement > ( ".search-highlight" ) ;
57+ if ( marks . length === 0 ) return ;
58+ // Remove outline from previous
59+ marks . forEach ( ( m ) => ( m . style . outline = "" ) ) ;
60+ const next = ( currentMatchIndex + dir + marks . length ) % marks . length ;
61+ setCurrentMatchIndex ( next ) ;
62+ marks [ next ] . scrollIntoView ( { behavior : "smooth" , block : "center" } ) ;
63+ marks [ next ] . style . outline = "2px solid #009edb" ;
64+ } ,
65+ [ currentMatchIndex ] ,
66+ ) ;
2867
2968 const handleShare = async ( ) => {
3069 await navigator . clipboard . writeText ( window . location . href ) ;
@@ -44,6 +83,7 @@ function HomeContent() {
4483 setLoading ( true ) ;
4584 setError ( null ) ;
4685 setDiffData ( null ) ;
86+ setSearchInput ( "" ) ;
4787
4888 try {
4989 const response = await fetch ( "/api/diff" , {
@@ -270,19 +310,131 @@ function HomeContent() {
270310 ) }
271311
272312 { hasQueryParams && diffData && (
273- < DiffViewer
274- data = { diffData }
275- left = { {
276- symbol : symbol1 ,
277- metadata : diffData . metadata ?. left ,
278- format : diffData . formats ?. left ,
279- } }
280- right = { {
281- symbol : symbol2 ,
282- metadata : diffData . metadata ?. right ,
283- format : diffData . formats ?. right ,
284- } }
285- />
313+ < >
314+ { /* Sticky search bar */ }
315+ < div className = "sticky top-4 z-10 -mx-3" >
316+ < div className = "rounded-xl bg-white/95 shadow-md ring-1 ring-black/5 backdrop-blur-sm px-4 py-2.5" >
317+ < div className = "flex items-center gap-2" >
318+ < div className = "relative flex-1" >
319+ < svg
320+ className = "absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400 pointer-events-none"
321+ fill = "none"
322+ viewBox = "0 0 24 24"
323+ stroke = "currentColor"
324+ strokeWidth = { 2 }
325+ >
326+ < path
327+ strokeLinecap = "round"
328+ strokeLinejoin = "round"
329+ d = "M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
330+ />
331+ </ svg >
332+ < input
333+ type = "text"
334+ value = { searchInput }
335+ onChange = { ( e ) => setSearchInput ( e . target . value ) }
336+ onKeyDown = { ( e ) => {
337+ if ( e . key === "Enter" )
338+ navigateMatch ( e . shiftKey ? - 1 : 1 ) ;
339+ if ( e . key === "Escape" ) setSearchInput ( "" ) ;
340+ } }
341+ placeholder = "Search in documents…"
342+ className = "w-full rounded-lg border border-gray-200 bg-gray-50 py-1.5 pr-9 pl-9 text-sm transition-colors focus:border-un-blue focus:bg-white focus:ring-1 focus:ring-un-blue focus:outline-none"
343+ />
344+ { searchInput && (
345+ < button
346+ onClick = { ( ) => setSearchInput ( "" ) }
347+ className = "absolute top-1/2 right-2.5 -translate-y-1/2 rounded text-gray-400 hover:text-gray-600"
348+ >
349+ < svg
350+ className = "h-3.5 w-3.5"
351+ fill = "none"
352+ viewBox = "0 0 24 24"
353+ stroke = "currentColor"
354+ strokeWidth = { 2.5 }
355+ >
356+ < path
357+ strokeLinecap = "round"
358+ strokeLinejoin = "round"
359+ d = "M6 18L18 6M6 6l12 12"
360+ />
361+ </ svg >
362+ </ button >
363+ ) }
364+ </ div >
365+
366+ { searchQuery && (
367+ < >
368+ < span className = "min-w-20 text-center text-xs tabular-nums text-gray-400" >
369+ { matchCount === 0
370+ ? "No matches"
371+ : `${ currentMatchIndex + 1 } / ${ matchCount } ` }
372+ </ span >
373+ < div className = "flex items-center gap-0.5 rounded-lg border border-gray-200 p-0.5" >
374+ < button
375+ onClick = { ( ) => navigateMatch ( - 1 ) }
376+ disabled = { matchCount === 0 }
377+ title = "Previous match (Shift+Enter)"
378+ className = "rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
379+ >
380+ < svg
381+ className = "h-3.5 w-3.5"
382+ fill = "none"
383+ viewBox = "0 0 24 24"
384+ stroke = "currentColor"
385+ strokeWidth = { 2.5 }
386+ >
387+ < path
388+ strokeLinecap = "round"
389+ strokeLinejoin = "round"
390+ d = "M5 15l7-7 7 7"
391+ />
392+ </ svg >
393+ </ button >
394+ < button
395+ onClick = { ( ) => navigateMatch ( 1 ) }
396+ disabled = { matchCount === 0 }
397+ title = "Next match (Enter)"
398+ className = "rounded-md p-1 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
399+ >
400+ < svg
401+ className = "h-3.5 w-3.5"
402+ fill = "none"
403+ viewBox = "0 0 24 24"
404+ stroke = "currentColor"
405+ strokeWidth = { 2.5 }
406+ >
407+ < path
408+ strokeLinecap = "round"
409+ strokeLinejoin = "round"
410+ d = "M19 9l-7 7-7-7"
411+ />
412+ </ svg >
413+ </ button >
414+ </ div >
415+ </ >
416+ ) }
417+ </ div >
418+ </ div >
419+ </ div >
420+
421+ < div ref = { diffContainerRef } >
422+ < DiffViewer
423+ data = { diffData }
424+ searchQuery = { searchQuery || undefined }
425+ left = { {
426+ symbol : symbol1 ,
427+ metadata : diffData . metadata ?. left ,
428+ format : diffData . formats ?. left ,
429+ } }
430+ right = { {
431+ symbol : symbol2 ,
432+ metadata : diffData . metadata ?. right ,
433+ format : diffData . formats ?. right ,
434+ } }
435+ />
436+ </ div >
437+ </ >
286438 ) }
287439
288440 { hasQueryParams && loading && ! diffData && (
0 commit comments