feat(client): try to stylize the quick search even further in the client

This commit is contained in:
perfectra1n 2025-08-02 23:36:42 -07:00
parent 6c79be881d
commit 2d358342c5
3 changed files with 143 additions and 46 deletions

View File

@ -23,12 +23,21 @@ 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: 8px 16px;
}
.quick-search .dropdown-item:hover {
background-color: #f8f9fa;
}
</style>
<div class="input-group-prepend">
@ -50,6 +59,8 @@ interface QuickSearchResponse {
noteTitle: string;
notePathTitle: string;
highlightedNotePathTitle: string;
contentSnippet?: string;
highlightedContentSnippet?: string;
icon: string;
}>;
error: string;
@ -151,7 +162,16 @@ export default class QuickSearchWidget extends BasicWidget {
if (!noteId) continue;
const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">');
$item.html(`<span class="${result.icon}"></span> ${result.highlightedNotePathTitle}`);
// Build the display HTML
let itemHtml = `<div><span class="${result.icon}"></span> ${result.highlightedNotePathTitle}</div>`;
// Add content snippet if available
if (result.highlightedContentSnippet) {
itemHtml += `<div style="font-size: 0.85em; color: #666; margin-left: 20px; margin-top: 2px;">${result.highlightedContentSnippet}</div>`;
}
$item.html(itemHtml);
$item.on("click", (e) => {
this.dropdown.hide();

View File

@ -29,6 +29,8 @@ class SearchResult {
score: number;
notePathTitle: string;
highlightedNotePathTitle?: string;
contentSnippet?: string;
highlightedContentSnippet?: string;
constructor(notePathArray: string[]) {
this.notePathArray = notePathArray;

View File

@ -17,6 +17,8 @@ import type { SearchParams, TokenStructure } from "./types.js";
import type Expression from "../expressions/expression.js";
import sql from "../../sql.js";
import scriptService from "../../script.js";
import striptags from "striptags";
import protectedSessionService from "../../protected_session.js";
export interface SearchNoteResult {
searchResultNoteIds: string[];
@ -337,6 +339,91 @@ function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BN
return searchResults.length > 0 ? becca.notes[searchResults[0].noteId] : null;
}
function extractContentSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
const note = becca.notes[noteId];
if (!note) {
return "";
}
// Only extract content for text-based notes
if (!["text", "code", "mermaid", "canvas", "mindMap"].includes(note.type)) {
return "";
}
try {
let content = note.getContent();
if (!content || typeof content !== "string") {
return "";
}
// Handle protected notes
if (note.isProtected && protectedSessionService.isProtectedSessionAvailable()) {
try {
content = protectedSessionService.decryptString(content) || "";
} catch (e) {
return ""; // Can't decrypt, don't show content
}
} else if (note.isProtected) {
return ""; // Protected but no session available
}
// Strip HTML tags for text notes
if (note.type === "text") {
content = striptags(content);
}
// Normalize whitespace
content = content.replace(/\s+/g, " ").trim();
if (!content) {
return "";
}
// Try to find a snippet around the first matching token
const normalizedContent = normalizeString(content.toLowerCase());
let snippetStart = 0;
let matchFound = false;
for (const token of searchTokens) {
const normalizedToken = normalizeString(token.toLowerCase());
const matchIndex = normalizedContent.indexOf(normalizedToken);
if (matchIndex !== -1) {
// Center the snippet around the match
snippetStart = Math.max(0, matchIndex - maxLength / 2);
matchFound = true;
break;
}
}
// Extract snippet
let snippet = content.substring(snippetStart, snippetStart + maxLength);
// Try to start/end at word boundaries
if (snippetStart > 0) {
const firstSpace = snippet.indexOf(" ");
if (firstSpace > 0 && firstSpace < 20) {
snippet = snippet.substring(firstSpace + 1);
}
snippet = "..." + snippet;
}
if (snippetStart + maxLength < content.length) {
const lastSpace = snippet.lastIndexOf(" ");
if (lastSpace > snippet.length - 20) {
snippet = snippet.substring(0, lastSpace);
}
snippet = snippet + "...";
}
return snippet;
} catch (e) {
log.error(`Error extracting content snippet for note ${noteId}: ${e}`);
return "";
}
}
function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const searchContext = new SearchContext({
fastSearch: fastSearch,
@ -351,6 +438,11 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const trimmed = allSearchResults.slice(0, 200);
// Extract content snippets
for (const result of trimmed) {
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
}
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
return trimmed.map((result) => {
@ -360,6 +452,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
noteTitle: title,
notePathTitle: result.notePathTitle,
highlightedNotePathTitle: result.highlightedNotePathTitle,
contentSnippet: result.contentSnippet,
highlightedContentSnippet: result.highlightedContentSnippet,
icon: icon ?? "bx bx-note"
};
});
@ -381,26 +475,11 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1));
for (const result of searchResults) {
const note = becca.notes[result.noteId];
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
if (highlightedTokens.find((token) => note.type.includes(token))) {
result.highlightedNotePathTitle += ` "type: ${note.type}'`;
}
if (highlightedTokens.find((token) => note.mime.includes(token))) {
result.highlightedNotePathTitle += ` "mime: ${note.mime}'`;
}
for (const attr of note.getAttributes()) {
if (attr.type === "relation" && attr.name === "internalLink" && ignoreInternalAttributes) {
continue;
}
if (highlightedTokens.find((token) => normalize(attr.name).includes(token) || normalize(attr.value).includes(token))) {
result.highlightedNotePathTitle += ` "${formatAttribute(attr)}'`;
}
// Initialize highlighted content snippet
if (result.contentSnippet) {
result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, "");
}
}
@ -419,40 +498,36 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
const tokenRegex = new RegExp(escapeRegExp(token), "gi");
let match;
// Find all matches
if (!result.highlightedNotePathTitle) {
continue;
}
while ((match = tokenRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
// Highlight in note path title
if (result.highlightedNotePathTitle) {
const titleRegex = new RegExp(escapeRegExp(token), "gi");
while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
// 2 characters are added, so we need to adjust the index
tokenRegex.lastIndex += 2;
titleRegex.lastIndex += 2;
}
}
// Highlight in content snippet
if (result.highlightedContentSnippet) {
const contentRegex = new RegExp(escapeRegExp(token), "gi");
while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) {
result.highlightedContentSnippet = wrapText(result.highlightedContentSnippet, match.index, token.length, "{", "}");
// 2 characters are added, so we need to adjust the index
contentRegex.lastIndex += 2;
}
}
}
}
for (const result of searchResults) {
if (!result.highlightedNotePathTitle) {
continue;
}
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/"/g, "<small>").replace(/'/g, "</small>").replace(/{/g, "<b>").replace(/}/g, "</b>");
}
if (result.highlightedNotePathTitle) {
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>");
}
function formatAttribute(attr: BAttribute) {
if (attr.type === "relation") {
return `~${escapeHtml(attr.name)}=…`;
} else if (attr.type === "label") {
let label = `#${escapeHtml(attr.name)}`;
if (attr.value) {
const val = /[^\w-]/.test(attr.value) ? `"${attr.value}"` : attr.value;
label += `=${escapeHtml(val)}`;
if (result.highlightedContentSnippet) {
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
}
return label;
}
}