import BasicWidget from "./basic_widget.js"; import server from "../services/server.js"; import linkService from "../services/link.js"; import froca from "../services/froca.js"; import utils from "../services/utils.js"; import appContext from "../components/app_context.js"; import shortcutService from "../services/shortcuts.js"; import { t } from "../services/i18n.js"; import { Dropdown, Tooltip } from "bootstrap"; const TPL = /*html*/` `; 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; attributeSnippet?: string; highlightedAttributeSnippet?: string; icon: string; }>; error: string; } 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); this.$searchString = this.$widget.find(".search-string"); this.$dropdownMenu = this.$widget.find(".dropdown-menu"); this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], { reference: this.$searchString[0], popperConfig: { strategy: "fixed", placement: "bottom" } }); 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) => { if (e.which === 13) { if (this.$dropdownMenu.is(":visible")) { this.search(); // just update already visible dropdown } else { this.dropdown.show(); } e.preventDefault(); e.stopPropagation(); } }); } shortcutService.bindElShortcut(this.$searchString, "return", () => { if (this.$dropdownMenu.is(":visible")) { this.search(); // just update already visible dropdown } else { this.dropdown.show(); } this.$searchString.focus(); }); shortcutService.bindElShortcut(this.$searchString, "down", () => { this.$dropdownMenu.find(".dropdown-item:not(.disabled):first").focus(); }); shortcutService.bindElShortcut(this.$searchString, "esc", () => { this.dropdown.hide(); }); return this.$widget; } async search() { const searchString = String(this.$searchString.val())?.trim(); if (!searchString) { this.dropdown.hide(); 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, searchResults, error } = await server.get(`quick-search/${encodeURIComponent(searchString)}`); if (error) { let tooltip = new Tooltip(this.$searchString[0], { trigger: "manual", title: `Search error: ${error}`, placement: "right" }); tooltip.show(); setTimeout(() => tooltip.dispose(), 4000); } // Store all results for infinite scrolling this.allSearchResults = searchResults || []; this.allSearchResultNoteIds = searchResultNoteIds || []; this.$dropdownMenu.empty(); if (this.allSearchResults.length === 0 && this.allSearchResultNoteIds.length === 0) { this.$dropdownMenu.append(`${t("quick-search.no-results")}`); return; } // 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 attribute snippet (tags/attributes) below the title if available if (result.highlightedAttributeSnippet) { itemHtml += `
${result.highlightedAttributeSnippet}
`; } // Add content snippet below the attributes 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(); const activeContext = appContext.tabManager.getActiveContext(); if (activeContext) { activeContext.setNote(note.noteId); } }); this.$dropdownMenu.append($link); } this.currentDisplayedCount = endIndex; } this.isLoadingMore = false; } 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($(`