diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index d7a8a81fe..a729b4d85 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -2255,6 +2255,13 @@ footer.webview-footer button { padding: 1px 10px 1px 10px; } +/* Search result highlighting */ +.search-result-title b, +.search-result-content b { + font-weight: 900; + color: var(--admonition-warning-accent-color); +} + /* Customized icons */ .bx-tn-toc::before { diff --git a/apps/client/src/widgets/quick_search.ts b/apps/client/src/widgets/quick_search.ts index 2d06baafe..094404182 100644 --- a/apps/client/src/widgets/quick_search.ts +++ b/apps/client/src/widgets/quick_search.ts @@ -23,12 +23,52 @@ const TPL = /*html*/` .quick-search .dropdown-menu { max-height: 600px; - max-width: 400px; + max-width: 600px; overflow-y: auto; overflow-x: hidden; text-overflow: ellipsis; box-shadow: -30px 50px 93px -50px black; } + + .quick-search .dropdown-item { + white-space: normal; + padding: 12px 16px; + line-height: 1.4; + position: relative; + } + + .quick-search .dropdown-item:not(:last-child)::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 80%; + height: 2px; + background: var(--main-border-color); + border-radius: 1px; + opacity: 0.4; + } + + .quick-search .dropdown-item:last-child::after { + display: none; + } + + .quick-search .dropdown-item.disabled::after { + display: none; + } + + .quick-search .dropdown-item.show-in-full-search::after { + display: none; + } + + .quick-search .dropdown-item:hover { + background-color: #f8f9fa; + } + + .quick-search .dropdown-divider { + margin: 0; + }
@@ -40,11 +80,21 @@ const TPL = /*html*/`
`; -const MAX_DISPLAYED_NOTES = 15; +const INITIAL_DISPLAYED_NOTES = 15; +const LOAD_MORE_BATCH_SIZE = 10; // TODO: Deduplicate with server. interface QuickSearchResponse { searchResultNoteIds: string[]; + searchResults?: Array<{ + notePath: string; + noteTitle: string; + notePathTitle: string; + highlightedNotePathTitle: string; + contentSnippet?: string; + highlightedContentSnippet?: string; + icon: string; + }>; error: string; } @@ -53,6 +103,12 @@ export default class QuickSearchWidget extends BasicWidget { private dropdown!: bootstrap.Dropdown; private $searchString!: JQuery; private $dropdownMenu!: JQuery; + + // State for infinite scrolling + private allSearchResults: Array = []; + private allSearchResultNoteIds: string[] = []; + private currentDisplayedCount: number = 0; + private isLoadingMore: boolean = false; doRender() { this.$widget = $(TPL); @@ -68,6 +124,11 @@ export default class QuickSearchWidget extends BasicWidget { }); this.$widget.find(".input-group-prepend").on("shown.bs.dropdown", () => this.search()); + + // Add scroll event listener for infinite scrolling + this.$dropdownMenu.on("scroll", () => { + this.handleScroll(); + }); if (utils.isMobile()) { this.$searchString.keydown((e) => { @@ -112,10 +173,16 @@ export default class QuickSearchWidget extends BasicWidget { return; } + // Reset state for new search + this.allSearchResults = []; + this.allSearchResultNoteIds = []; + this.currentDisplayedCount = 0; + this.isLoadingMore = false; + this.$dropdownMenu.empty(); this.$dropdownMenu.append(`${t("quick-search.searching")}`); - const { searchResultNoteIds, error } = await server.get(`quick-search/${encodeURIComponent(searchString)}`); + const { searchResultNoteIds, searchResults, error } = await server.get(`quick-search/${encodeURIComponent(searchString)}`); if (error) { let tooltip = new Tooltip(this.$searchString[0], { @@ -129,47 +196,148 @@ export default class QuickSearchWidget extends BasicWidget { setTimeout(() => tooltip.dispose(), 4000); } - const displayedNoteIds = searchResultNoteIds.slice(0, Math.min(MAX_DISPLAYED_NOTES, searchResultNoteIds.length)); + // Store all results for infinite scrolling + this.allSearchResults = searchResults || []; + this.allSearchResultNoteIds = searchResultNoteIds || []; this.$dropdownMenu.empty(); - if (displayedNoteIds.length === 0) { + if (this.allSearchResults.length === 0 && this.allSearchResultNoteIds.length === 0) { this.$dropdownMenu.append(`${t("quick-search.no-results")}`); + return; } - for (const note of await froca.getNotes(displayedNoteIds)) { - const $link = await linkService.createLink(note.noteId, { showNotePath: true, showNoteIcon: true }); - $link.addClass("dropdown-item"); - $link.attr("tabIndex", "0"); - $link.on("click", (e) => { - this.dropdown.hide(); + // Display initial batch + await this.displayMoreResults(INITIAL_DISPLAYED_NOTES); + this.addShowInFullSearchButton(); + + this.dropdown.update(); + } + + private async displayMoreResults(batchSize: number) { + if (this.isLoadingMore) return; + this.isLoadingMore = true; + + // Remove the "Show in full search" button temporarily + this.$dropdownMenu.find('.show-in-full-search').remove(); + this.$dropdownMenu.find('.dropdown-divider').remove(); + + // Use highlighted search results if available, otherwise fall back to basic display + if (this.allSearchResults.length > 0) { + const startIndex = this.currentDisplayedCount; + const endIndex = Math.min(startIndex + batchSize, this.allSearchResults.length); + const resultsToDisplay = this.allSearchResults.slice(startIndex, endIndex); + + for (const result of resultsToDisplay) { + const noteId = result.notePath.split("/").pop(); + if (!noteId) continue; + + const $item = $(''); + + // Build the display HTML with content snippet below the title + let itemHtml = `
+
+ + ${result.highlightedNotePathTitle} +
`; + + // Add content snippet below the title if available + if (result.highlightedContentSnippet) { + itemHtml += `
${result.highlightedContentSnippet}
`; + } + + itemHtml += `
`; + + $item.html(itemHtml); + + $item.on("click", (e) => { + this.dropdown.hide(); + e.preventDefault(); + + const activeContext = appContext.tabManager.getActiveContext(); + if (activeContext) { + activeContext.setNote(noteId); + } + }); + + shortcutService.bindElShortcut($item, "return", () => { + this.dropdown.hide(); + + const activeContext = appContext.tabManager.getActiveContext(); + if (activeContext) { + activeContext.setNote(noteId); + } + }); + + this.$dropdownMenu.append($item); + } + + this.currentDisplayedCount = endIndex; + } else { + // Fallback to original behavior if no highlighted results + const startIndex = this.currentDisplayedCount; + const endIndex = Math.min(startIndex + batchSize, this.allSearchResultNoteIds.length); + const noteIdsToDisplay = this.allSearchResultNoteIds.slice(startIndex, endIndex); + + for (const note of await froca.getNotes(noteIdsToDisplay)) { + const $link = await linkService.createLink(note.noteId, { showNotePath: true, showNoteIcon: true }); + $link.addClass("dropdown-item"); + $link.attr("tabIndex", "0"); + $link.on("click", (e) => { + this.dropdown.hide(); + + if (!e.target || e.target.nodeName !== "A") { + // click on the link is handled by link handling, but we want the whole item clickable + const activeContext = appContext.tabManager.getActiveContext(); + if (activeContext) { + activeContext.setNote(note.noteId); + } + } + }); + shortcutService.bindElShortcut($link, "return", () => { + this.dropdown.hide(); - if (!e.target || e.target.nodeName !== "A") { - // click on the link is handled by link handling, but we want the whole item clickable const activeContext = appContext.tabManager.getActiveContext(); if (activeContext) { activeContext.setNote(note.noteId); } - } - }); - shortcutService.bindElShortcut($link, "return", () => { - this.dropdown.hide(); + }); - const activeContext = appContext.tabManager.getActiveContext(); - if (activeContext) { - activeContext.setNote(note.noteId); - } - }); + this.$dropdownMenu.append($link); + } - this.$dropdownMenu.append($link); + this.currentDisplayedCount = endIndex; } - if (searchResultNoteIds.length > MAX_DISPLAYED_NOTES) { - const numRemainingResults = searchResultNoteIds.length - MAX_DISPLAYED_NOTES; - this.$dropdownMenu.append(`${t("quick-search.more-results", { number: numRemainingResults })}`); - } + this.isLoadingMore = false; + } - const $showInFullButton = $('
').text(t("quick-search.show-in-full-search")); + private handleScroll() { + if (this.isLoadingMore) return; + + const dropdown = this.$dropdownMenu[0]; + const scrollTop = dropdown.scrollTop; + const scrollHeight = dropdown.scrollHeight; + const clientHeight = dropdown.clientHeight; + + // Trigger loading more when user scrolls near the bottom (within 50px) + if (scrollTop + clientHeight >= scrollHeight - 50) { + const totalResults = this.allSearchResults.length > 0 ? this.allSearchResults.length : this.allSearchResultNoteIds.length; + + if (this.currentDisplayedCount < totalResults) { + this.displayMoreResults(LOAD_MORE_BATCH_SIZE).then(() => { + this.addShowInFullSearchButton(); + }); + } + } + } + + private addShowInFullSearchButton() { + // Remove existing button if it exists + this.$dropdownMenu.find('.show-in-full-search').remove(); + this.$dropdownMenu.find('.dropdown-divider').remove(); + + const $showInFullButton = $('').text(t("quick-search.show-in-full-search")); this.$dropdownMenu.append($(`