@@ -23,12 +23,52 @@ const TPL = /*html*/`
2323
2424 .quick-search .dropdown-menu {
2525 max-height: 600px;
26- max-width: 400px ;
26+ max-width: 600px ;
2727 overflow-y: auto;
2828 overflow-x: hidden;
2929 text-overflow: ellipsis;
3030 box-shadow: -30px 50px 93px -50px black;
3131 }
32+
33+ .quick-search .dropdown-item {
34+ white-space: normal;
35+ padding: 12px 16px;
36+ line-height: 1.4;
37+ position: relative;
38+ }
39+
40+ .quick-search .dropdown-item:not(:last-child)::after {
41+ content: '';
42+ position: absolute;
43+ bottom: 0;
44+ left: 50%;
45+ transform: translateX(-50%);
46+ width: 80%;
47+ height: 2px;
48+ background: var(--main-border-color);
49+ border-radius: 1px;
50+ opacity: 0.4;
51+ }
52+
53+ .quick-search .dropdown-item:last-child::after {
54+ display: none;
55+ }
56+
57+ .quick-search .dropdown-item.disabled::after {
58+ display: none;
59+ }
60+
61+ .quick-search .dropdown-item.show-in-full-search::after {
62+ display: none;
63+ }
64+
65+ .quick-search .dropdown-item:hover {
66+ background-color: #f8f9fa;
67+ }
68+
69+ .quick-search .dropdown-divider {
70+ margin: 0;
71+ }
3272 </style>
3373
3474 <div class="input-group-prepend">
@@ -40,11 +80,21 @@ const TPL = /*html*/`
4080 <input type="text" class="form-control form-control-sm search-string" placeholder="${ t ( "quick-search.placeholder" ) } ">
4181</div>` ;
4282
43- const MAX_DISPLAYED_NOTES = 15 ;
83+ const INITIAL_DISPLAYED_NOTES = 15 ;
84+ const LOAD_MORE_BATCH_SIZE = 10 ;
4485
4586// TODO: Deduplicate with server.
4687interface QuickSearchResponse {
4788 searchResultNoteIds : string [ ] ;
89+ searchResults ?: Array < {
90+ notePath : string ;
91+ noteTitle : string ;
92+ notePathTitle : string ;
93+ highlightedNotePathTitle : string ;
94+ contentSnippet ?: string ;
95+ highlightedContentSnippet ?: string ;
96+ icon : string ;
97+ } > ;
4898 error : string ;
4999}
50100
@@ -53,6 +103,12 @@ export default class QuickSearchWidget extends BasicWidget {
53103 private dropdown ! : bootstrap . Dropdown ;
54104 private $searchString ! : JQuery < HTMLElement > ;
55105 private $dropdownMenu ! : JQuery < HTMLElement > ;
106+
107+ // State for infinite scrolling
108+ private allSearchResults : Array < any > = [ ] ;
109+ private allSearchResultNoteIds : string [ ] = [ ] ;
110+ private currentDisplayedCount : number = 0 ;
111+ private isLoadingMore : boolean = false ;
56112
57113 doRender ( ) {
58114 this . $widget = $ ( TPL ) ;
@@ -68,6 +124,11 @@ export default class QuickSearchWidget extends BasicWidget {
68124 } ) ;
69125
70126 this . $widget . find ( ".input-group-prepend" ) . on ( "shown.bs.dropdown" , ( ) => this . search ( ) ) ;
127+
128+ // Add scroll event listener for infinite scrolling
129+ this . $dropdownMenu . on ( "scroll" , ( ) => {
130+ this . handleScroll ( ) ;
131+ } ) ;
71132
72133 if ( utils . isMobile ( ) ) {
73134 this . $searchString . keydown ( ( e ) => {
@@ -112,10 +173,16 @@ export default class QuickSearchWidget extends BasicWidget {
112173 return ;
113174 }
114175
176+ // Reset state for new search
177+ this . allSearchResults = [ ] ;
178+ this . allSearchResultNoteIds = [ ] ;
179+ this . currentDisplayedCount = 0 ;
180+ this . isLoadingMore = false ;
181+
115182 this . $dropdownMenu . empty ( ) ;
116183 this . $dropdownMenu . append ( `<span class="dropdown-item disabled"><span class="bx bx-loader bx-spin"></span>${ t ( "quick-search.searching" ) } </span>` ) ;
117184
118- const { searchResultNoteIds, error } = await server . get < QuickSearchResponse > ( `quick-search/${ encodeURIComponent ( searchString ) } ` ) ;
185+ const { searchResultNoteIds, searchResults , error } = await server . get < QuickSearchResponse > ( `quick-search/${ encodeURIComponent ( searchString ) } ` ) ;
119186
120187 if ( error ) {
121188 let tooltip = new Tooltip ( this . $searchString [ 0 ] , {
@@ -129,47 +196,148 @@ export default class QuickSearchWidget extends BasicWidget {
129196 setTimeout ( ( ) => tooltip . dispose ( ) , 4000 ) ;
130197 }
131198
132- const displayedNoteIds = searchResultNoteIds . slice ( 0 , Math . min ( MAX_DISPLAYED_NOTES , searchResultNoteIds . length ) ) ;
199+ // Store all results for infinite scrolling
200+ this . allSearchResults = searchResults || [ ] ;
201+ this . allSearchResultNoteIds = searchResultNoteIds || [ ] ;
133202
134203 this . $dropdownMenu . empty ( ) ;
135204
136- if ( displayedNoteIds . length === 0 ) {
205+ if ( this . allSearchResults . length === 0 && this . allSearchResultNoteIds . length === 0 ) {
137206 this . $dropdownMenu . append ( `<span class="dropdown-item disabled">${ t ( "quick-search.no-results" ) } </span>` ) ;
207+ return ;
138208 }
139209
140- for ( const note of await froca . getNotes ( displayedNoteIds ) ) {
141- const $link = await linkService . createLink ( note . noteId , { showNotePath : true , showNoteIcon : true } ) ;
142- $link . addClass ( "dropdown-item" ) ;
143- $link . attr ( "tabIndex" , "0" ) ;
144- $link . on ( "click" , ( e ) => {
145- this . dropdown . hide ( ) ;
210+ // Display initial batch
211+ await this . displayMoreResults ( INITIAL_DISPLAYED_NOTES ) ;
212+ this . addShowInFullSearchButton ( ) ;
213+
214+ this . dropdown . update ( ) ;
215+ }
216+
217+ private async displayMoreResults ( batchSize : number ) {
218+ if ( this . isLoadingMore ) return ;
219+ this . isLoadingMore = true ;
220+
221+ // Remove the "Show in full search" button temporarily
222+ this . $dropdownMenu . find ( '.show-in-full-search' ) . remove ( ) ;
223+ this . $dropdownMenu . find ( '.dropdown-divider' ) . remove ( ) ;
224+
225+ // Use highlighted search results if available, otherwise fall back to basic display
226+ if ( this . allSearchResults . length > 0 ) {
227+ const startIndex = this . currentDisplayedCount ;
228+ const endIndex = Math . min ( startIndex + batchSize , this . allSearchResults . length ) ;
229+ const resultsToDisplay = this . allSearchResults . slice ( startIndex , endIndex ) ;
230+
231+ for ( const result of resultsToDisplay ) {
232+ const noteId = result . notePath . split ( "/" ) . pop ( ) ;
233+ if ( ! noteId ) continue ;
234+
235+ const $item = $ ( '<a class="dropdown-item" tabindex="0" href="javascript:">' ) ;
236+
237+ // Build the display HTML with content snippet below the title
238+ let itemHtml = `<div style="display: flex; flex-direction: column;">
239+ <div style="display: flex; align-items: flex-start; gap: 6px;">
240+ <span class="${ result . icon } " style="flex-shrink: 0; margin-top: 1px;"></span>
241+ <span style="flex: 1;" class="search-result-title">${ result . highlightedNotePathTitle } </span>
242+ </div>` ;
243+
244+ // Add content snippet below the title if available
245+ if ( result . highlightedContentSnippet ) {
246+ itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${ result . highlightedContentSnippet } </div>` ;
247+ }
248+
249+ itemHtml += `</div>` ;
250+
251+ $item . html ( itemHtml ) ;
252+
253+ $item . on ( "click" , ( e ) => {
254+ this . dropdown . hide ( ) ;
255+ e . preventDefault ( ) ;
256+
257+ const activeContext = appContext . tabManager . getActiveContext ( ) ;
258+ if ( activeContext ) {
259+ activeContext . setNote ( noteId ) ;
260+ }
261+ } ) ;
262+
263+ shortcutService . bindElShortcut ( $item , "return" , ( ) => {
264+ this . dropdown . hide ( ) ;
265+
266+ const activeContext = appContext . tabManager . getActiveContext ( ) ;
267+ if ( activeContext ) {
268+ activeContext . setNote ( noteId ) ;
269+ }
270+ } ) ;
271+
272+ this . $dropdownMenu . append ( $item ) ;
273+ }
274+
275+ this . currentDisplayedCount = endIndex ;
276+ } else {
277+ // Fallback to original behavior if no highlighted results
278+ const startIndex = this . currentDisplayedCount ;
279+ const endIndex = Math . min ( startIndex + batchSize , this . allSearchResultNoteIds . length ) ;
280+ const noteIdsToDisplay = this . allSearchResultNoteIds . slice ( startIndex , endIndex ) ;
281+
282+ for ( const note of await froca . getNotes ( noteIdsToDisplay ) ) {
283+ const $link = await linkService . createLink ( note . noteId , { showNotePath : true , showNoteIcon : true } ) ;
284+ $link . addClass ( "dropdown-item" ) ;
285+ $link . attr ( "tabIndex" , "0" ) ;
286+ $link . on ( "click" , ( e ) => {
287+ this . dropdown . hide ( ) ;
288+
289+ if ( ! e . target || e . target . nodeName !== "A" ) {
290+ // click on the link is handled by link handling, but we want the whole item clickable
291+ const activeContext = appContext . tabManager . getActiveContext ( ) ;
292+ if ( activeContext ) {
293+ activeContext . setNote ( note . noteId ) ;
294+ }
295+ }
296+ } ) ;
297+ shortcutService . bindElShortcut ( $link , "return" , ( ) => {
298+ this . dropdown . hide ( ) ;
146299
147- if ( ! e . target || e . target . nodeName !== "A" ) {
148- // click on the link is handled by link handling, but we want the whole item clickable
149300 const activeContext = appContext . tabManager . getActiveContext ( ) ;
150301 if ( activeContext ) {
151302 activeContext . setNote ( note . noteId ) ;
152303 }
153- }
154- } ) ;
155- shortcutService . bindElShortcut ( $link , "return" , ( ) => {
156- this . dropdown . hide ( ) ;
304+ } ) ;
157305
158- const activeContext = appContext . tabManager . getActiveContext ( ) ;
159- if ( activeContext ) {
160- activeContext . setNote ( note . noteId ) ;
161- }
162- } ) ;
306+ this . $dropdownMenu . append ( $link ) ;
307+ }
163308
164- this . $dropdownMenu . append ( $link ) ;
309+ this . currentDisplayedCount = endIndex ;
165310 }
166311
167- if ( searchResultNoteIds . length > MAX_DISPLAYED_NOTES ) {
168- const numRemainingResults = searchResultNoteIds . length - MAX_DISPLAYED_NOTES ;
169- this . $dropdownMenu . append ( `<span class="dropdown-item disabled">${ t ( "quick-search.more-results" , { number : numRemainingResults } ) } </span>` ) ;
312+ this . isLoadingMore = false ;
313+ }
314+
315+ private handleScroll ( ) {
316+ if ( this . isLoadingMore ) return ;
317+
318+ const dropdown = this . $dropdownMenu [ 0 ] ;
319+ const scrollTop = dropdown . scrollTop ;
320+ const scrollHeight = dropdown . scrollHeight ;
321+ const clientHeight = dropdown . clientHeight ;
322+
323+ // Trigger loading more when user scrolls near the bottom (within 50px)
324+ if ( scrollTop + clientHeight >= scrollHeight - 50 ) {
325+ const totalResults = this . allSearchResults . length > 0 ? this . allSearchResults . length : this . allSearchResultNoteIds . length ;
326+
327+ if ( this . currentDisplayedCount < totalResults ) {
328+ this . displayMoreResults ( LOAD_MORE_BATCH_SIZE ) . then ( ( ) => {
329+ this . addShowInFullSearchButton ( ) ;
330+ } ) ;
331+ }
170332 }
333+ }
334+
335+ private addShowInFullSearchButton ( ) {
336+ // Remove existing button if it exists
337+ this . $dropdownMenu . find ( '.show-in-full-search' ) . remove ( ) ;
338+ this . $dropdownMenu . find ( '.dropdown-divider' ) . remove ( ) ;
171339
172- const $showInFullButton = $ ( '<a class="dropdown-item" tabindex="0">' ) . text ( t ( "quick-search.show-in-full-search" ) ) ;
340+ const $showInFullButton = $ ( '<a class="dropdown-item show-in-full-search " tabindex="0">' ) . text ( t ( "quick-search.show-in-full-search" ) ) ;
173341
174342 this . $dropdownMenu . append ( $ ( `<div class="dropdown-divider">` ) ) ;
175343 this . $dropdownMenu . append ( $showInFullButton ) ;
0 commit comments