@@ -25,7 +25,7 @@ import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes";
2525import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression" ;
2626import { Result } from "~/utils/types" ;
2727import { getSetting } from "~/utils/extensionSettings" ;
28- import fuzzy from "fuzzy " ;
28+ import MiniSearch from "minisearch " ;
2929
3030type Props = {
3131 textarea : HTMLTextAreaElement ;
@@ -34,6 +34,13 @@ type Props = {
3434 triggerText : string ;
3535} ;
3636
37+ type MinisearchResult = Result & {
38+ type : string ;
39+ } ;
40+
41+ const MIN_SEARCH_SCORE = 0.1 ;
42+ const MAX_ITEMS_PER_TYPE = 10 ;
43+
3744const waitForBlock = ( {
3845 uid,
3946 text,
@@ -76,7 +83,6 @@ const NodeSearchMenu = ({
7683 const [ discourseTypes , setDiscourseTypes ] = useState < DiscourseNode [ ] > ( [ ] ) ;
7784 const [ checkedTypes , setCheckedTypes ] = useState < Record < string , boolean > > ( { } ) ;
7885 const [ isLoading , setIsLoading ] = useState ( true ) ;
79- const [ allNodes , setAllNodes ] = useState < Record < string , Result [ ] > > ( { } ) ;
8086 const [ searchResults , setSearchResults ] = useState < Record < string , Result [ ] > > (
8187 { } ,
8288 ) ;
@@ -90,7 +96,8 @@ const NodeSearchMenu = ({
9096 [ typeIds , checkedTypes ] ,
9197 ) ;
9298 const scrollContainerRef = useRef < HTMLDivElement | null > ( null ) ;
93- const searchTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
99+ const searchTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
100+ const miniSearchRef = useRef < MiniSearch < MinisearchResult > | null > ( null ) ;
94101 const POPOVER_TOP_OFFSET = 30 ;
95102
96103 const debouncedSearchTerm = useCallback ( ( term : string ) => {
@@ -135,16 +142,79 @@ const NodeSearchMenu = ({
135142 }
136143 } ;
137144
138- const filterNodesLocally = useCallback (
139- ( nodes : Result [ ] , searchTerm : string ) : Result [ ] => {
140- if ( ! searchTerm . trim ( ) ) return nodes ;
145+ const searchWithMiniSearch = useCallback (
146+ ( searchTerm : string , typeFilter ?: string [ ] ) : Record < string , Result [ ] > => {
147+ if ( ! miniSearchRef . current ) {
148+ return { } ;
149+ }
141150
142- return fuzzy
143- . filter ( searchTerm , nodes , {
144- extract : ( node ) => node . text ,
145- } )
146- . map ( ( result ) => result . original )
147- . filter ( ( node ) : node is Result => ! ! node ) ;
151+ const search = miniSearchRef . current ;
152+
153+ if ( ! searchTerm . trim ( ) ) {
154+ if ( ! typeFilter ) {
155+ return { } ;
156+ }
157+
158+ const allResults : Record < string , Result [ ] > = { } ;
159+ typeFilter . forEach ( ( type ) => {
160+ const results = (
161+ search . search ( MiniSearch . wildcard , {
162+ filter : ( result ) =>
163+ ( result as unknown as MinisearchResult ) . type === type ,
164+ } ) as unknown as MinisearchResult [ ]
165+ )
166+ . slice ( 0 , MAX_ITEMS_PER_TYPE )
167+ . map ( ( r ) => ( {
168+ text : r . text ,
169+ uid : r . uid ,
170+ } ) ) ;
171+ allResults [ type ] = results ;
172+ } ) ;
173+
174+ return allResults ;
175+ }
176+
177+ const rawSearchResults = search . search ( searchTerm , {
178+ fields : [ "text" ] ,
179+ fuzzy : 0.2 ,
180+ prefix : true ,
181+ combineWith : "AND" ,
182+ filter : typeFilter
183+ ? ( result ) =>
184+ typeFilter . includes ( ( result as unknown as MinisearchResult ) . type )
185+ : undefined ,
186+ } ) ;
187+
188+ const filteredResults = rawSearchResults . filter (
189+ ( r ) => r . score > MIN_SEARCH_SCORE ,
190+ ) ;
191+
192+ const searchResults = (
193+ filteredResults as unknown as MinisearchResult [ ]
194+ ) . map ( ( r ) => ( {
195+ text : r . text ,
196+ uid : r . uid ,
197+ type : r . type ,
198+ } ) ) ;
199+
200+ const results = searchResults . reduce (
201+ ( acc , result ) => {
202+ if ( ! acc [ result . type ] ) {
203+ acc [ result . type ] = [ ] ;
204+ }
205+ if ( acc [ result . type ] . length < MAX_ITEMS_PER_TYPE ) {
206+ acc [ result . type ] . push ( {
207+ id : result . uid ,
208+ text : result . text ,
209+ uid : result . uid ,
210+ } ) ;
211+ }
212+ return acc ;
213+ } ,
214+ { } as Record < string , Result [ ] > ,
215+ ) ;
216+
217+ return results ;
148218 } ,
149219 [ ] ,
150220 ) ;
@@ -169,7 +239,30 @@ const NodeSearchMenu = ({
169239 allNodeTypes . forEach ( ( type ) => {
170240 allNodesCache [ type . type ] = searchNodesForType ( type ) ;
171241 } ) ;
172- setAllNodes ( allNodesCache ) ;
242+
243+ const miniSearch = new MiniSearch < MinisearchResult > ( {
244+ fields : [ "text" ] ,
245+ storeFields : [ "text" , "uid" , "type" ] ,
246+ idField : "uid" ,
247+ } ) ;
248+
249+ const documentsToIndex : MinisearchResult [ ] = [ ] ;
250+ const seenUids = new Set < string > ( ) ;
251+
252+ allNodeTypes . forEach ( ( type ) => {
253+ const nodes = allNodesCache [ type . type ] || [ ] ;
254+ nodes . forEach ( ( node ) => {
255+ if ( seenUids . has ( node . uid ) ) return ;
256+ seenUids . add ( node . uid ) ;
257+ documentsToIndex . push ( {
258+ ...node ,
259+ type : type . type ,
260+ } ) ;
261+ } ) ;
262+ } ) ;
263+
264+ miniSearch . addAll ( documentsToIndex ) ;
265+ miniSearchRef . current = miniSearch ;
173266
174267 const initialSearchResults = Object . fromEntries (
175268 allNodeTypes . map ( ( type ) => [ type . type , [ ] ] ) ,
@@ -183,17 +276,21 @@ const NodeSearchMenu = ({
183276 } , [ ] ) ;
184277
185278 useEffect ( ( ) => {
186- if ( isLoading || Object . keys ( allNodes ) . length === 0 ) return ;
279+ if ( isLoading || ! miniSearchRef . current ) return ;
187280
188- const newResults : Record < string , Result [ ] > = { } ;
189-
190- discourseTypes . forEach ( ( type ) => {
191- const cachedNodes = allNodes [ type . type ] || [ ] ;
192- newResults [ type . type ] = filterNodesLocally ( cachedNodes , searchTerm ) ;
193- } ) ;
281+ const selectedTypes = discourseTypes
282+ . filter ( ( type ) => checkedTypes [ type . type ] )
283+ . map ( ( type ) => type . type ) ;
194284
285+ const newResults = searchWithMiniSearch ( searchTerm , selectedTypes ) ;
195286 setSearchResults ( newResults ) ;
196- } , [ searchTerm , isLoading , allNodes , discourseTypes , filterNodesLocally ] ) ;
287+ } , [
288+ searchTerm ,
289+ isLoading ,
290+ discourseTypes ,
291+ checkedTypes ,
292+ searchWithMiniSearch ,
293+ ] ) ;
197294
198295 const menuRef = useRef < HTMLUListElement > ( null ) ;
199296 const { [ "block-uid" ] : blockUid , [ "window-id" ] : windowId } = useMemo (
@@ -232,61 +329,61 @@ const NodeSearchMenu = ({
232329
233330 const onSelect = useCallback (
234331 ( item : Result ) => {
235- if ( ! blockUid ) {
236- onClose ( ) ;
237- return ;
238- }
239- void waitForBlock ( { uid : blockUid , text : textarea . value } )
240- . then ( ( ) => {
241- onClose ( ) ;
242-
243- setTimeout ( ( ) => {
244- const originalText = getTextByBlockUid ( blockUid ) ;
245-
246- const prefix = originalText . substring ( 0 , triggerPosition ) ;
247- const suffix = originalText . substring ( textarea . selectionStart ) ;
248- const pageRef = `[[${ item . text } ]]` ;
249-
250- const newText = `${ prefix } ${ pageRef } ${ suffix } ` ;
251- void updateBlock ( { uid : blockUid , text : newText } ) . then ( ( ) => {
252- const newCursorPosition = triggerPosition + pageRef . length ;
253-
254- if ( window . roamAlphaAPI . ui . setBlockFocusAndSelection ) {
255- void window . roamAlphaAPI . ui . setBlockFocusAndSelection ( {
256- location : {
257- // eslint-disable-next-line @typescript-eslint/naming-convention
258- "block-uid" : blockUid ,
259- // eslint-disable-next-line @typescript-eslint/naming-convention
260- "window-id" : windowId ,
261- } ,
262- selection : { start : newCursorPosition } ,
263- } ) ;
264- } else {
265- setTimeout ( ( ) => {
266- const textareaElements =
267- document . querySelectorAll ( "textarea" ) ;
268- for ( const el of textareaElements ) {
269- if ( getUids ( el ) . blockUid === blockUid ) {
270- el . focus ( ) ;
271- el . setSelectionRange (
272- newCursorPosition ,
273- newCursorPosition ,
274- ) ;
275- break ;
276- }
277- }
278- } , 50 ) ;
279- }
280- } ) ;
281- posthog . capture ( "Discourse Node: Selected from Search Menu" , {
282- id : item . id ,
283- text : item . text ,
284- } ) ;
285- } , 10 ) ;
286- } )
287- . catch ( ( error ) => {
288- console . error ( "Error waiting for block:" , error ) ;
289- } ) ;
332+ if ( ! blockUid ) {
333+ onClose ( ) ;
334+ return ;
335+ }
336+ void waitForBlock ( { uid : blockUid , text : textarea . value } )
337+ . then ( ( ) => {
338+ onClose ( ) ;
339+
340+ setTimeout ( ( ) => {
341+ const originalText = getTextByBlockUid ( blockUid ) ;
342+
343+ const prefix = originalText . substring ( 0 , triggerPosition ) ;
344+ const suffix = originalText . substring ( textarea . selectionStart ) ;
345+ const pageRef = `[[${ item . text } ]]` ;
346+
347+ const newText = `${ prefix } ${ pageRef } ${ suffix } ` ;
348+ void updateBlock ( { uid : blockUid , text : newText } ) . then ( ( ) => {
349+ const newCursorPosition = triggerPosition + pageRef . length ;
350+
351+ if ( window . roamAlphaAPI . ui . setBlockFocusAndSelection ) {
352+ void window . roamAlphaAPI . ui . setBlockFocusAndSelection ( {
353+ location : {
354+ // eslint-disable-next-line @typescript-eslint/naming-convention
355+ "block-uid" : blockUid ,
356+ // eslint-disable-next-line @typescript-eslint/naming-convention
357+ "window-id" : windowId ,
358+ } ,
359+ selection : { start : newCursorPosition } ,
360+ } ) ;
361+ } else {
362+ setTimeout ( ( ) => {
363+ const textareaElements =
364+ document . querySelectorAll ( "textarea" ) ;
365+ for ( const el of textareaElements ) {
366+ if ( getUids ( el ) . blockUid === blockUid ) {
367+ el . focus ( ) ;
368+ el . setSelectionRange (
369+ newCursorPosition ,
370+ newCursorPosition ,
371+ ) ;
372+ break ;
373+ }
374+ }
375+ } , 50 ) ;
376+ }
377+ } ) ;
378+ posthog . capture ( "Discourse Node: Selected from Search Menu" , {
379+ id : item . id ,
380+ text : item . text ,
381+ } ) ;
382+ } , 10 ) ;
383+ } )
384+ . catch ( ( error ) => {
385+ console . error ( "Error waiting for block:" , error ) ;
386+ } ) ;
290387 } ,
291388 [ blockUid , onClose , textarea , triggerPosition , windowId ] ,
292389 ) ;
0 commit comments