mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 07:46:30 +01:00
Compare commits
3 Commits
feat/push-
...
feat/fix-j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a94f0b217b | ||
|
|
e8f08bcdfe | ||
|
|
843be0da22 |
@@ -30,6 +30,8 @@ export interface Suggestion {
|
||||
notePathTitle?: string;
|
||||
notePath?: string;
|
||||
highlightedNotePathTitle?: string;
|
||||
attributeSnippet?: string;
|
||||
highlightedAttributeSnippet?: string;
|
||||
action?: string | "create-note" | "search-notes" | "external-link" | "command";
|
||||
parentNoteId?: string;
|
||||
icon?: string;
|
||||
@@ -308,11 +310,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
displayKey: "notePathTitle",
|
||||
templates: {
|
||||
suggestion: (suggestion) => {
|
||||
// Handle different suggestion types
|
||||
if (suggestion.action === "command") {
|
||||
let html = `<div class="command-suggestion">`;
|
||||
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
|
||||
html += `<div class="command-content">`;
|
||||
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
|
||||
html += `<div class="command-name">${suggestion.highlightedNotePathTitle || suggestion.noteTitle || ''}</div>`;
|
||||
if (suggestion.commandDescription) {
|
||||
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
|
||||
}
|
||||
@@ -323,7 +326,20 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
|
||||
|
||||
// For note suggestions, match Quick Search structure
|
||||
// Title row with icon
|
||||
let html = `<div style="display: flex; align-items: center; gap: 6px;">`;
|
||||
html += `<span class="${suggestion.icon ?? "bx bx-note"}" style="flex-shrink: 0;"></span>`;
|
||||
html += `<span class="search-result-title" style="flex: 1;">${suggestion.highlightedNotePathTitle || ''}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Add attribute snippet if available (inline display)
|
||||
if (suggestion.highlightedAttributeSnippet && suggestion.highlightedAttributeSnippet.trim()) {
|
||||
html += `<div class="search-result-attributes" style="margin-left: 20px; margin-top: 2px; color: var(--muted-text-color); font-size: 0.9em;">${suggestion.highlightedAttributeSnippet}</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
|
||||
62
apps/client/src/services/quick_search_renderer.ts
Normal file
62
apps/client/src/services/quick_search_renderer.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Quick Search specific result renderer
|
||||
*
|
||||
* This module provides HTML rendering functionality specifically for the Quick Search widget.
|
||||
* The Jump To dialog (note_autocomplete) intentionally has its own inline rendering logic
|
||||
* with different styling and layout requirements.
|
||||
*
|
||||
* SECURITY NOTE: HTML Snippet Handling
|
||||
* The highlighted snippet fields (highlightedAttributeSnippet) contain
|
||||
* pre-sanitized HTML from the server. The server-side processing:
|
||||
* 1. Escapes all HTML using the escape-html library
|
||||
* 2. Adds safe HTML tags for display: <b> for search term highlighting
|
||||
* 3. See apps/server/src/services/search/services/search.ts for implementation
|
||||
*
|
||||
* This means the HTML snippets can be safely inserted without additional escaping on the client side.
|
||||
*/
|
||||
|
||||
import type { Suggestion } from "./note_autocomplete.js";
|
||||
|
||||
/**
|
||||
* Creates HTML for a Quick Search result item
|
||||
*
|
||||
* @param result - The search result item to render
|
||||
* @returns HTML string formatted for Quick Search widget display
|
||||
*/
|
||||
export function createSearchResultHtml(result: Suggestion): string {
|
||||
// Handle command action
|
||||
if (result.action === "command") {
|
||||
let html = `<div class="command-suggestion">`;
|
||||
html += `<span class="command-icon ${result.icon || "bx bx-terminal"}"></span>`;
|
||||
html += `<div class="command-content">`;
|
||||
html += `<div class="command-name">${result.highlightedNotePathTitle || ''}</div>`;
|
||||
if (result.commandDescription) {
|
||||
html += `<div class="command-description">${result.commandDescription}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
if (result.commandShortcut) {
|
||||
html += `<kbd class="command-shortcut">${result.commandShortcut}</kbd>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Default: render as note result
|
||||
// Wrap everything in a flex column container
|
||||
let itemHtml = `<div style="display: flex; flex-direction: column; gap: 2px;">`;
|
||||
|
||||
// Title row with icon
|
||||
itemHtml += `<div style="display: flex; align-items: center; gap: 6px;">`;
|
||||
itemHtml += `<span class="${result.icon || 'bx bx-note'}" style="flex-shrink: 0;"></span>`;
|
||||
itemHtml += `<span class="search-result-title" style="flex: 1;">${result.highlightedNotePathTitle || result.notePathTitle || ''}</span>`;
|
||||
itemHtml += `</div>`;
|
||||
|
||||
// Add attribute snippet if available (inline display)
|
||||
if (result.highlightedAttributeSnippet && result.highlightedAttributeSnippet.trim()) {
|
||||
itemHtml += `<div class="search-result-attributes" style="margin-left: 20px; color: var(--muted-text-color); font-size: 0.9em;">${result.highlightedAttributeSnippet}</div>`;
|
||||
}
|
||||
|
||||
itemHtml += `</div>`;
|
||||
|
||||
return itemHtml;
|
||||
}
|
||||
@@ -840,8 +840,25 @@ table.promoted-attributes-in-tooltip th {
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
position: relative;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Add separator between Jump To suggestions like Quick Search */
|
||||
.jump-to-note-results .aa-suggestion: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;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion p {
|
||||
@@ -1786,7 +1803,7 @@ textarea {
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestions {
|
||||
padding: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Command palette styling */
|
||||
@@ -2260,13 +2277,43 @@ footer.webview-footer button {
|
||||
padding: 1px 10px 1px 10px;
|
||||
}
|
||||
|
||||
/* Search result highlighting */
|
||||
/* Search result highlighting - applies to both Quick Search and Jump To */
|
||||
.search-result-title b,
|
||||
.search-result-content b {
|
||||
.search-result-content b,
|
||||
.search-result-attributes b,
|
||||
.quick-search .search-result-title b,
|
||||
.quick-search .search-result-content b,
|
||||
.quick-search .search-result-attributes b {
|
||||
font-weight: 900;
|
||||
color: var(--admonition-warning-accent-color);
|
||||
}
|
||||
|
||||
/* Quick Search specific snippet styling */
|
||||
.quick-search .search-result-content {
|
||||
font-size: 0.85em;
|
||||
color: var(--main-text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.quick-search .search-result-attributes {
|
||||
font-size: 0.75em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Jump To (autocomplete) specific snippet styling */
|
||||
.aa-dropdown-menu .search-result-content {
|
||||
font-size: 0.82em;
|
||||
color: var(--main-text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .search-result-attributes {
|
||||
font-size: 0.75em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Customized icons */
|
||||
|
||||
.bx-tn-toc::before {
|
||||
|
||||
@@ -530,17 +530,16 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
}
|
||||
|
||||
/* List item */
|
||||
.jump-to-note-dialog .aa-suggestions div,
|
||||
.note-detail-empty .aa-suggestions div {
|
||||
.jump-to-note-dialog .aa-suggestions .aa-suggestion,
|
||||
.note-detail-empty .aa-suggestions .aa-suggestion {
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: var(--menu-text-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Selected list item */
|
||||
.jump-to-note-dialog .aa-suggestions div.aa-cursor,
|
||||
.note-detail-empty .aa-suggestions div.aa-cursor {
|
||||
.jump-to-note-dialog .aa-suggestions .aa-suggestion.aa-cursor,
|
||||
.note-detail-empty .aa-suggestions .aa-suggestion.aa-cursor {
|
||||
background: var(--hover-item-background-color);
|
||||
color: var(--hover-item-text-color);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import appContext from "../components/app_context.js";
|
||||
import shortcutService from "../services/shortcuts.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import { Dropdown, Tooltip } from "bootstrap";
|
||||
import { createSearchResultHtml } from "../services/quick_search_renderer.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="quick-search input-group input-group-sm">
|
||||
@@ -91,8 +92,6 @@ interface QuickSearchResponse {
|
||||
noteTitle: string;
|
||||
notePathTitle: string;
|
||||
highlightedNotePathTitle: string;
|
||||
contentSnippet?: string;
|
||||
highlightedContentSnippet?: string;
|
||||
attributeSnippet?: string;
|
||||
highlightedAttributeSnippet?: string;
|
||||
icon: string;
|
||||
@@ -236,24 +235,14 @@ export default class QuickSearchWidget extends BasicWidget {
|
||||
|
||||
const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">');
|
||||
|
||||
// Build the display HTML with content snippet below the title
|
||||
let itemHtml = `<div style="display: flex; flex-direction: column;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 6px;">
|
||||
<span class="${result.icon}" style="flex-shrink: 0; margin-top: 1px;"></span>
|
||||
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
|
||||
</div>`;
|
||||
|
||||
// Add attribute snippet (tags/attributes) below the title if available
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
itemHtml += `<div style="font-size: 0.75em; color: var(--muted-text-color); opacity: 0.5; margin-left: 20px; margin-top: 2px; line-height: 1.2;" class="search-result-attributes">${result.highlightedAttributeSnippet}</div>`;
|
||||
}
|
||||
|
||||
// Add content snippet below the attributes if available
|
||||
if (result.highlightedContentSnippet) {
|
||||
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>`;
|
||||
}
|
||||
|
||||
itemHtml += `</div>`;
|
||||
// Use the shared renderer for consistent display
|
||||
const itemHtml = createSearchResultHtml({
|
||||
icon: result.icon,
|
||||
notePathTitle: result.notePathTitle,
|
||||
highlightedNotePathTitle: result.highlightedNotePathTitle,
|
||||
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
|
||||
highlightedContentSnippet: result.highlightedContentSnippet
|
||||
});
|
||||
|
||||
$item.html(itemHtml);
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ class SearchResult {
|
||||
score: number;
|
||||
notePathTitle: string;
|
||||
highlightedNotePathTitle?: string;
|
||||
contentSnippet?: string;
|
||||
highlightedContentSnippet?: string;
|
||||
attributeSnippet?: string;
|
||||
highlightedAttributeSnippet?: string;
|
||||
private fuzzyScore: number; // Track fuzzy score separately
|
||||
|
||||
@@ -285,15 +285,19 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena
|
||||
|
||||
const noteSet = expression.execute(allNoteSet, executionContext, searchContext);
|
||||
|
||||
const searchResults = noteSet.notes.map((note) => {
|
||||
const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath();
|
||||
const searchResults = noteSet.notes
|
||||
.map((note) => {
|
||||
const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath();
|
||||
|
||||
if (!notePathArray) {
|
||||
throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`);
|
||||
}
|
||||
if (!notePathArray) {
|
||||
// Log the orphaned note but don't throw - just skip it
|
||||
log.info(`Skipping orphaned note without path: ${note.noteId} "${note.title}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SearchResult(notePathArray);
|
||||
});
|
||||
return new SearchResult(notePathArray);
|
||||
})
|
||||
.filter(result => result !== null) as SearchResult[];
|
||||
|
||||
for (const res of searchResults) {
|
||||
res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching);
|
||||
@@ -497,6 +501,12 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found in content, always show the beginning of the note
|
||||
// This ensures users always get context even when searching by tags/attributes
|
||||
if (!matchFound) {
|
||||
snippetStart = 0;
|
||||
}
|
||||
|
||||
// Extract snippet
|
||||
let snippet = content.substring(snippetStart, snippetStart + maxLength);
|
||||
|
||||
@@ -575,11 +585,29 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
|
||||
}
|
||||
}
|
||||
|
||||
// If no matching attributes found but we have attributes, show the first few
|
||||
// This provides context even when searching didn't match attributes
|
||||
if (matchingAttributes.length === 0 && attributes.length > 0) {
|
||||
// Filter out internal attributes if not searching for them
|
||||
const visibleAttributes = attributes.filter(attr =>
|
||||
!attr.name?.startsWith("internal") &&
|
||||
!attr.name?.startsWith("dateCreated") &&
|
||||
!attr.name?.startsWith("dateModified")
|
||||
);
|
||||
|
||||
// Take up to 4 visible attributes
|
||||
matchingAttributes = visibleAttributes.slice(0, 4).map(attr => ({
|
||||
name: attr.name || "",
|
||||
value: attr.value || "",
|
||||
type: attr.type || ""
|
||||
}));
|
||||
}
|
||||
|
||||
if (matchingAttributes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Limit to 4 lines maximum, similar to content snippet logic
|
||||
// Display attributes inline, separated by spaces
|
||||
const lines: string[] = [];
|
||||
for (const attr of matchingAttributes.slice(0, 4)) {
|
||||
let line = "";
|
||||
@@ -597,7 +625,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
|
||||
}
|
||||
}
|
||||
|
||||
let snippet = lines.join('\n');
|
||||
let snippet = lines.join(' '); // Join with spaces instead of newlines
|
||||
|
||||
// Apply length limit while preserving line structure
|
||||
if (snippet.length > maxLength) {
|
||||
@@ -637,9 +665,8 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
||||
|
||||
const trimmed = allSearchResults.slice(0, 200);
|
||||
|
||||
// Extract content and attribute snippets
|
||||
// Extract attribute snippets only (content snippets removed for performance)
|
||||
for (const result of trimmed) {
|
||||
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
result.attributeSnippet = extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
}
|
||||
|
||||
@@ -652,8 +679,6 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
||||
noteTitle: title,
|
||||
notePathTitle: result.notePathTitle,
|
||||
highlightedNotePathTitle: result.highlightedNotePathTitle,
|
||||
contentSnippet: result.contentSnippet,
|
||||
highlightedContentSnippet: result.highlightedContentSnippet,
|
||||
attributeSnippet: result.attributeSnippet,
|
||||
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
|
||||
icon: icon ?? "bx bx-note"
|
||||
@@ -679,17 +704,9 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
||||
for (const result of searchResults) {
|
||||
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
|
||||
|
||||
// Initialize highlighted content snippet
|
||||
if (result.contentSnippet) {
|
||||
// Escape HTML but preserve newlines for later conversion to <br>
|
||||
result.highlightedContentSnippet = escapeHtml(result.contentSnippet);
|
||||
// Remove any stray < { } that might interfere with our highlighting markers
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, "");
|
||||
}
|
||||
|
||||
// Initialize highlighted attribute snippet
|
||||
if (result.attributeSnippet) {
|
||||
// Escape HTML but preserve newlines for later conversion to <br>
|
||||
// Escape HTML
|
||||
result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet);
|
||||
// Remove any stray < { } that might interfere with our highlighting markers
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/[<{}]/g, "");
|
||||
@@ -721,16 +738,6 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight in attribute snippet
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
const attributeRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
@@ -748,18 +755,10 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
|
||||
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
}
|
||||
|
||||
if (result.highlightedContentSnippet) {
|
||||
// Replace highlighting markers with HTML tags
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
// Convert newlines to <br> tags for HTML display
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
// Replace highlighting markers with HTML tags
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
// Convert newlines to <br> tags for HTML display
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "<br>");
|
||||
// Keep inline display - no conversion of newlines needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user