1- import { useEffect , useMemo , useRef , useState } from "react" ;
2-
3- export default function NodeSearchBar ( { nodes, onJumpToNode } ) {
4- const [ query , setQuery ] = useState ( "" ) ;
5- const inputRef = useRef ( null ) ;
6-
7- const results = useMemo ( ( ) => {
8- const trimmed = query . trim ( ) . toLowerCase ( ) ;
9- if ( ! trimmed ) return [ ] ;
10-
11- return nodes
12- . filter ( ( node ) => {
13- const title = node . data ?. title || "" ;
14- return title . toLowerCase ( ) . includes ( trimmed ) ;
15- } )
16- . slice ( 0 , 8 ) ;
17- } , [ nodes , query ] ) ;
18-
19- useEffect ( ( ) => {
20- function handleGlobalKeyDown ( event ) {
21- const activeTag = document . activeElement ?. tagName || "" ;
22- const isTypingInInput =
23- activeTag === "INPUT" ||
24- activeTag === "TEXTAREA" ||
25- document . activeElement ?. isContentEditable ;
26-
27- if ( event . key === "/" && ! isTypingInInput ) {
28- event . preventDefault ( ) ;
29- inputRef . current ?. focus ( ) ;
30- }
31-
32- if ( event . key === "Escape" && document . activeElement === inputRef . current ) {
33- setQuery ( "" ) ;
34- inputRef . current ?. blur ( ) ;
35- }
36- }
37-
38- window . addEventListener ( "keydown" , handleGlobalKeyDown ) ;
39- return ( ) => window . removeEventListener ( "keydown" , handleGlobalKeyDown ) ;
40- } , [ ] ) ;
41-
42- function handleSelect ( nodeId ) {
43- onJumpToNode ( nodeId ) ;
44- setQuery ( "" ) ;
45- inputRef . current ?. blur ( ) ;
46- }
47-
48- function handleInputKeyDown ( event ) {
49- if ( event . key === "Enter" && results . length ) {
50- event . preventDefault ( ) ;
51- handleSelect ( results [ 0 ] . id ) ;
52- }
53-
54- if ( event . key === "Escape" ) {
55- setQuery ( "" ) ;
56- inputRef . current ?. blur ( ) ;
57- }
58- }
59-
60- function highlightMatch ( text ) {
61- const safeText = text || "Untitled Block" ;
62- const trimmed = query . trim ( ) ;
63-
64- if ( ! trimmed ) return safeText ;
65-
66- const lowerText = safeText . toLowerCase ( ) ;
67- const lowerQuery = trimmed . toLowerCase ( ) ;
68- const matchIndex = lowerText . indexOf ( lowerQuery ) ;
69-
70- if ( matchIndex === - 1 ) return safeText ;
71-
72- const before = safeText . slice ( 0 , matchIndex ) ;
73- const match = safeText . slice ( matchIndex , matchIndex + trimmed . length ) ;
74- const after = safeText . slice ( matchIndex + trimmed . length ) ;
75-
76- return (
77- < >
78- { before }
79- < mark > { match } </ mark >
80- { after }
81- </ >
82- ) ;
83- }
84-
85- return (
86- < div className = "node-search" >
87- < input
88- ref = { inputRef }
89- className = "node-search-input"
90- type = "text"
91- placeholder = "Search nodes..."
92- value = { query }
93- onChange = { ( e ) => setQuery ( e . target . value ) }
94- onKeyDown = { handleInputKeyDown }
95- />
96-
97- { query . trim ( ) && (
98- < div className = "node-search-results" >
99- { results . length === 0 ? (
100- < div className = "node-search-empty" > No matching nodes</ div >
101- ) : (
102- results . map ( ( node ) => (
103- < button
104- key = { node . id }
105- className = "node-search-result"
106- onClick = { ( ) => handleSelect ( node . id ) }
107- >
108- < span className = "node-search-title" >
109- { highlightMatch ( node . data ?. title || "Untitled Block" ) }
110- </ span >
111- < span className = "node-search-type" >
112- { node . data ?. blockType || "narrative" }
113- </ span >
114- </ button >
115- ) )
116- ) }
117- </ div >
118- ) }
119- </ div >
120- ) ;
121- }
1+ import {
2+ useEffect ,
3+ useMemo ,
4+ useRef ,
5+ useState ,
6+ type ChangeEvent ,
7+ type KeyboardEvent ,
8+ type ReactNode ,
9+ } from "react" ;
10+ import type { StoryNode } from "../../types/story" ;
11+
12+ /**
13+ * Canvas node search control.
14+ */
15+ export interface NodeSearchBarProps {
16+ nodes : readonly StoryNode [ ] ;
17+ onJumpToNode : ( nodeId : string ) => void ;
18+ }
19+
20+ export default function NodeSearchBar ( {
21+ nodes,
22+ onJumpToNode,
23+ } : NodeSearchBarProps ) {
24+ const [ query , setQuery ] = useState ( "" ) ;
25+ const inputRef = useRef < HTMLInputElement | null > ( null ) ;
26+
27+ const results = useMemo ( ( ) => {
28+ const trimmed = query . trim ( ) . toLowerCase ( ) ;
29+ if ( ! trimmed ) return [ ] ;
30+
31+ return nodes
32+ . filter ( ( node ) => {
33+ const title = node . data ?. title || "" ;
34+ return title . toLowerCase ( ) . includes ( trimmed ) ;
35+ } )
36+ . slice ( 0 , 8 ) ;
37+ } , [ nodes , query ] ) ;
38+
39+ useEffect ( ( ) => {
40+ function handleGlobalKeyDown ( event : globalThis . KeyboardEvent ) {
41+ const active = document . activeElement as HTMLElement | null ;
42+ const activeTag = active ?. tagName || "" ;
43+ const isTypingInInput =
44+ activeTag === "INPUT" ||
45+ activeTag === "TEXTAREA" ||
46+ active ?. isContentEditable ;
47+
48+ if ( event . key === "/" && ! isTypingInInput ) {
49+ event . preventDefault ( ) ;
50+ inputRef . current ?. focus ( ) ;
51+ }
52+
53+ if ( event . key === "Escape" && active === inputRef . current ) {
54+ setQuery ( "" ) ;
55+ inputRef . current ?. blur ( ) ;
56+ }
57+ }
58+
59+ window . addEventListener ( "keydown" , handleGlobalKeyDown ) ;
60+ return ( ) => window . removeEventListener ( "keydown" , handleGlobalKeyDown ) ;
61+ } , [ ] ) ;
62+
63+ function handleSelect ( nodeId : string ) {
64+ onJumpToNode ( nodeId ) ;
65+ setQuery ( "" ) ;
66+ inputRef . current ?. blur ( ) ;
67+ }
68+
69+ function handleInputKeyDown ( event : KeyboardEvent < HTMLInputElement > ) {
70+ if ( event . key === "Enter" && results . length ) {
71+ event . preventDefault ( ) ;
72+ handleSelect ( results [ 0 ] . id ) ;
73+ }
74+
75+ if ( event . key === "Escape" ) {
76+ setQuery ( "" ) ;
77+ inputRef . current ?. blur ( ) ;
78+ }
79+ }
80+
81+ function highlightMatch ( text : string | undefined ) : ReactNode {
82+ const safeText = text || "Untitled Block" ;
83+ const trimmed = query . trim ( ) ;
84+
85+ if ( ! trimmed ) return safeText ;
86+
87+ const lowerText = safeText . toLowerCase ( ) ;
88+ const lowerQuery = trimmed . toLowerCase ( ) ;
89+ const matchIndex = lowerText . indexOf ( lowerQuery ) ;
90+
91+ if ( matchIndex === - 1 ) return safeText ;
92+
93+ const before = safeText . slice ( 0 , matchIndex ) ;
94+ const match = safeText . slice ( matchIndex , matchIndex + trimmed . length ) ;
95+ const after = safeText . slice ( matchIndex + trimmed . length ) ;
96+
97+ return (
98+ < >
99+ { before }
100+ < mark > { match } </ mark >
101+ { after }
102+ </ >
103+ ) ;
104+ }
105+
106+ return (
107+ < div className = "node-search" >
108+ < input
109+ ref = { inputRef }
110+ className = "node-search-input"
111+ type = "text"
112+ placeholder = "Search nodes..."
113+ value = { query }
114+ onChange = { ( e : ChangeEvent < HTMLInputElement > ) => setQuery ( e . target . value ) }
115+ onKeyDown = { handleInputKeyDown }
116+ />
117+
118+ { query . trim ( ) && (
119+ < div className = "node-search-results" >
120+ { results . length === 0 ? (
121+ < div className = "node-search-empty" > No matching nodes</ div >
122+ ) : (
123+ results . map ( ( node ) => (
124+ < button
125+ key = { node . id }
126+ className = "node-search-result"
127+ onClick = { ( ) => handleSelect ( node . id ) }
128+ >
129+ < span className = "node-search-title" >
130+ { highlightMatch ( node . data ?. title || "Untitled Block" ) }
131+ </ span >
132+ < span className = "node-search-type" >
133+ { node . data ?. blockType || "narrative" }
134+ </ span >
135+ </ button >
136+ ) )
137+ ) }
138+ </ div >
139+ ) }
140+ </ div >
141+ ) ;
142+ }
0 commit comments