mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 15:19:01 +02:00
feat(client): try to stylize the quick search even further in the client
This commit is contained in:
parent
6c79be881d
commit
2d358342c5
@ -23,12 +23,21 @@ const TPL = /*html*/`
|
|||||||
|
|
||||||
.quick-search .dropdown-menu {
|
.quick-search .dropdown-menu {
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
max-width: 400px;
|
max-width: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
box-shadow: -30px 50px 93px -50px black;
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
@ -50,6 +59,8 @@ interface QuickSearchResponse {
|
|||||||
noteTitle: string;
|
noteTitle: string;
|
||||||
notePathTitle: string;
|
notePathTitle: string;
|
||||||
highlightedNotePathTitle: string;
|
highlightedNotePathTitle: string;
|
||||||
|
contentSnippet?: string;
|
||||||
|
highlightedContentSnippet?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
}>;
|
}>;
|
||||||
error: string;
|
error: string;
|
||||||
@ -151,7 +162,16 @@ export default class QuickSearchWidget extends BasicWidget {
|
|||||||
if (!noteId) continue;
|
if (!noteId) continue;
|
||||||
|
|
||||||
const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">');
|
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) => {
|
$item.on("click", (e) => {
|
||||||
this.dropdown.hide();
|
this.dropdown.hide();
|
||||||
|
@ -29,6 +29,8 @@ class SearchResult {
|
|||||||
score: number;
|
score: number;
|
||||||
notePathTitle: string;
|
notePathTitle: string;
|
||||||
highlightedNotePathTitle?: string;
|
highlightedNotePathTitle?: string;
|
||||||
|
contentSnippet?: string;
|
||||||
|
highlightedContentSnippet?: string;
|
||||||
|
|
||||||
constructor(notePathArray: string[]) {
|
constructor(notePathArray: string[]) {
|
||||||
this.notePathArray = notePathArray;
|
this.notePathArray = notePathArray;
|
||||||
|
@ -17,6 +17,8 @@ import type { SearchParams, TokenStructure } from "./types.js";
|
|||||||
import type Expression from "../expressions/expression.js";
|
import type Expression from "../expressions/expression.js";
|
||||||
import sql from "../../sql.js";
|
import sql from "../../sql.js";
|
||||||
import scriptService from "../../script.js";
|
import scriptService from "../../script.js";
|
||||||
|
import striptags from "striptags";
|
||||||
|
import protectedSessionService from "../../protected_session.js";
|
||||||
|
|
||||||
export interface SearchNoteResult {
|
export interface SearchNoteResult {
|
||||||
searchResultNoteIds: string[];
|
searchResultNoteIds: string[];
|
||||||
@ -337,6 +339,91 @@ function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BN
|
|||||||
return searchResults.length > 0 ? becca.notes[searchResults[0].noteId] : null;
|
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) {
|
function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
||||||
const searchContext = new SearchContext({
|
const searchContext = new SearchContext({
|
||||||
fastSearch: fastSearch,
|
fastSearch: fastSearch,
|
||||||
@ -351,6 +438,11 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
|||||||
|
|
||||||
const trimmed = allSearchResults.slice(0, 200);
|
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);
|
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
|
||||||
|
|
||||||
return trimmed.map((result) => {
|
return trimmed.map((result) => {
|
||||||
@ -360,6 +452,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
|||||||
noteTitle: title,
|
noteTitle: title,
|
||||||
notePathTitle: result.notePathTitle,
|
notePathTitle: result.notePathTitle,
|
||||||
highlightedNotePathTitle: result.highlightedNotePathTitle,
|
highlightedNotePathTitle: result.highlightedNotePathTitle,
|
||||||
|
contentSnippet: result.contentSnippet,
|
||||||
|
highlightedContentSnippet: result.highlightedContentSnippet,
|
||||||
icon: icon ?? "bx bx-note"
|
icon: icon ?? "bx bx-note"
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -381,26 +475,11 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
|||||||
highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1));
|
highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1));
|
||||||
|
|
||||||
for (const result of searchResults) {
|
for (const result of searchResults) {
|
||||||
const note = becca.notes[result.noteId];
|
|
||||||
|
|
||||||
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
|
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
|
||||||
|
|
||||||
if (highlightedTokens.find((token) => note.type.includes(token))) {
|
// Initialize highlighted content snippet
|
||||||
result.highlightedNotePathTitle += ` "type: ${note.type}'`;
|
if (result.contentSnippet) {
|
||||||
}
|
result.highlightedContentSnippet = escapeHtml(result.contentSnippet).replace(/[<{}]/g, "");
|
||||||
|
|
||||||
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)}'`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,40 +498,36 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
|||||||
const tokenRegex = new RegExp(escapeRegExp(token), "gi");
|
const tokenRegex = new RegExp(escapeRegExp(token), "gi");
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
// Find all matches
|
// Highlight in note path title
|
||||||
if (!result.highlightedNotePathTitle) {
|
if (result.highlightedNotePathTitle) {
|
||||||
continue;
|
const titleRegex = new RegExp(escapeRegExp(token), "gi");
|
||||||
}
|
while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
|
||||||
while ((match = tokenRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
|
|
||||||
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
|
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
|
||||||
|
|
||||||
// 2 characters are added, so we need to adjust the index
|
// 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) {
|
for (const result of searchResults) {
|
||||||
if (!result.highlightedNotePathTitle) {
|
if (result.highlightedNotePathTitle) {
|
||||||
continue;
|
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||||
}
|
|
||||||
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/"/g, "<small>").replace(/'/g, "</small>").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)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return label;
|
if (result.highlightedContentSnippet) {
|
||||||
|
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user