mirror of
https://github.com/quartz-community/search.git
synced 2026-07-22 02:50:25 +00:00
feat: overhaul search with multi-tag support, autocomplete, and quality fixes
- Fix highlight() trim bug: slice tokenized text array, correct window math - Fix memory leak: use event delegation instead of per-element listeners - Fix XSS: escape content with escapeHTML before innerHTML insertion - Add multi-tag search (#tag1 #tag2 query) with AND filtering - Add tag autocomplete dropdown with keyboard navigation - Add inline ghost text for selected tag suggestion - Add configurable result ordering via fieldPriority option - Add scroll-to-highlight in preview panel - Add ARIA attributes (listbox, option, aria-expanded, aria-label) - Add error handling in fetchContent (try/catch, !res.ok check) - Add 150ms debounce on preview updates - Add index initialization guard for tag dropdown - Use parallel index filling (Promise.all) - Remove debug console.log statements - Restore button refocus after hiding search - Use CSS custom property for z-index management - Add max-height constraint for mobile results - Update all 6 locales with ellipsis placeholder and tag search strings - Export SearchField type
This commit is contained in:
parent
b40d509e85
commit
4cfc4b2110
17 changed files with 584 additions and 147 deletions
2
dist/components/index.d.ts
vendored
2
dist/components/index.d.ts
vendored
|
|
@ -1,2 +1,2 @@
|
|||
export { Search, SearchOptions } from '../index.js';
|
||||
export { Search, SearchField, SearchOptions } from '../index.js';
|
||||
import '@quartz-community/types';
|
||||
|
|
|
|||
80
dist/components/index.js
vendored
80
dist/components/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/components/index.js.map
vendored
2
dist/components/index.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/index.d.ts
vendored
4
dist/index.d.ts
vendored
|
|
@ -1,8 +1,10 @@
|
|||
import { QuartzComponent } from '@quartz-community/types';
|
||||
|
||||
type SearchField = "title" | "content" | "tags";
|
||||
interface SearchOptions {
|
||||
enablePreview: boolean;
|
||||
fieldPriority: SearchField[];
|
||||
}
|
||||
declare const _default: (userOpts?: Partial<SearchOptions>) => QuartzComponent;
|
||||
|
||||
export { _default as Search, type SearchOptions };
|
||||
export { _default as Search, type SearchField, type SearchOptions };
|
||||
|
|
|
|||
80
dist/index.js
vendored
80
dist/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -9,12 +9,16 @@ import style from "./styles/search.scss";
|
|||
// @ts-expect-error - inline script imported as string by esbuild loader
|
||||
import script from "./scripts/search.inline.ts";
|
||||
|
||||
export type SearchField = "title" | "content" | "tags";
|
||||
|
||||
export interface SearchOptions {
|
||||
enablePreview: boolean;
|
||||
fieldPriority: SearchField[];
|
||||
}
|
||||
|
||||
const defaultOptions: SearchOptions = {
|
||||
enablePreview: true,
|
||||
fieldPriority: ["title", "content", "tags"],
|
||||
};
|
||||
|
||||
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||
|
|
@ -25,7 +29,11 @@ export default ((userOpts?: Partial<SearchOptions>) => {
|
|||
|
||||
return (
|
||||
<div class={classNames(displayClass, "search")}>
|
||||
<button class="search-button">
|
||||
<button
|
||||
class="search-button"
|
||||
aria-label={i18n(locale).components.search.title}
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
|
||||
<title>Search</title>
|
||||
<g class="search-path" fill="none">
|
||||
|
|
@ -45,7 +53,11 @@ export default ((userOpts?: Partial<SearchOptions>) => {
|
|||
aria-label={searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div class="search-layout" data-preview={opts.enablePreview}></div>
|
||||
<div
|
||||
class="search-layout"
|
||||
data-preview={opts.enablePreview}
|
||||
data-field-priority={JSON.stringify(opts.fieldPriority)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { default as Search } from "./Search";
|
||||
export type { SearchOptions } from "./Search";
|
||||
export type { SearchOptions, SearchField } from "./Search";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
normalizeRelativeURLs,
|
||||
registerEscapeHandler,
|
||||
resolveBasePath,
|
||||
escapeHTML,
|
||||
} from "@quartz-community/utils";
|
||||
|
||||
interface Item {
|
||||
|
|
@ -81,7 +82,31 @@ const index = new FlexSearch.Document({
|
|||
|
||||
let contentData: Record<string, Item> | null = null;
|
||||
let idDataMap: string[] = [];
|
||||
let allTags: string[] = [];
|
||||
const fetchContentCache = new Map<string, Element[]>();
|
||||
|
||||
function parseSearchQuery(input: string): { tags: string[]; query: string } {
|
||||
const tokens = input.split(/\s+/);
|
||||
const tags: string[] = [];
|
||||
const queryParts: string[] = [];
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith("#") && token.length > 1) {
|
||||
tags.push(token.substring(1));
|
||||
} else if (token !== "#") {
|
||||
queryParts.push(token);
|
||||
}
|
||||
}
|
||||
return { tags, query: queryParts.join(" ").trim() };
|
||||
}
|
||||
|
||||
function getCurrentTagToken(input: string): string | null {
|
||||
const tokens = input.split(/\s+/);
|
||||
const last = tokens[tokens.length - 1];
|
||||
if (last && last.startsWith("#")) {
|
||||
return last.substring(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
|
||||
async function fetchContent(slug: string): Promise<Element[]> {
|
||||
|
|
@ -89,15 +114,18 @@ async function fetchContent(slug: string): Promise<Element[]> {
|
|||
return fetchContentCache.get(slug) as Element[];
|
||||
}
|
||||
const targetUrl = new URL(resolveBasePath(slug), window.location.origin).toString();
|
||||
const contents = await fetch(targetUrl)
|
||||
.then((res) => res.text())
|
||||
.then((contents) => {
|
||||
const html = parser.parseFromString(contents ?? "", "text/html");
|
||||
normalizeRelativeURLs(html, targetUrl);
|
||||
return Array.from(html.getElementsByClassName("popover-hint"));
|
||||
});
|
||||
fetchContentCache.set(slug, contents);
|
||||
return contents;
|
||||
try {
|
||||
const res = await fetch(targetUrl);
|
||||
if (!res.ok) return [];
|
||||
const text = await res.text();
|
||||
const html = parser.parseFromString(text ?? "", "text/html");
|
||||
normalizeRelativeURLs(html, targetUrl);
|
||||
const contents = Array.from(html.getElementsByClassName("popover-hint"));
|
||||
fetchContentCache.set(slug, contents);
|
||||
return contents;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupFns: Array<() => void> = [];
|
||||
|
|
@ -116,18 +144,24 @@ async function setupSearch() {
|
|||
|
||||
for (const searchEl of Array.from(searchElements)) {
|
||||
const container = searchEl.querySelector(".search-container") as HTMLElement | null;
|
||||
const searchButton = searchEl.querySelector(".search-button");
|
||||
const searchButton = searchEl.querySelector(".search-button") as HTMLElement | null;
|
||||
const searchBar = searchEl.querySelector(".search-bar") as HTMLInputElement;
|
||||
const searchLayout = searchEl.querySelector(".search-layout");
|
||||
|
||||
if (!container || !searchButton || !searchBar || !searchLayout) continue;
|
||||
|
||||
const enablePreview = searchLayout.getAttribute("data-preview") === "true";
|
||||
const fieldPriorityAttr = searchLayout.getAttribute("data-field-priority");
|
||||
const fieldPriority: string[] = fieldPriorityAttr
|
||||
? JSON.parse(fieldPriorityAttr)
|
||||
: ["title", "content", "tags"];
|
||||
|
||||
let results = searchLayout.querySelector(".results-container") as HTMLDivElement | null;
|
||||
if (!results) {
|
||||
results = document.createElement("div");
|
||||
results.className = "results-container";
|
||||
results.setAttribute("role", "listbox");
|
||||
results.setAttribute("aria-label", "Search results");
|
||||
searchLayout.appendChild(results);
|
||||
}
|
||||
|
||||
|
|
@ -138,63 +172,171 @@ async function setupSearch() {
|
|||
searchLayout.appendChild(preview);
|
||||
}
|
||||
|
||||
const tagDropdown = document.createElement("div");
|
||||
tagDropdown.className = "tag-suggestions";
|
||||
tagDropdown.setAttribute("role", "listbox");
|
||||
tagDropdown.setAttribute("aria-label", "Tag suggestions");
|
||||
tagDropdown.style.display = "none";
|
||||
const searchSpace = searchBar.parentElement!;
|
||||
searchSpace.insertBefore(tagDropdown, searchBar.nextSibling);
|
||||
|
||||
const ghostText = document.createElement("span");
|
||||
ghostText.className = "ghost-text";
|
||||
ghostText.setAttribute("aria-hidden", "true");
|
||||
searchSpace.insertBefore(ghostText, searchBar.nextSibling);
|
||||
|
||||
let tagSuggestionIndex = -1;
|
||||
let filteredTags: string[] = [];
|
||||
let tagDropdownVisible = false;
|
||||
|
||||
const updateGhostText = (partial: string) => {
|
||||
if (tagSuggestionIndex < 0 || tagSuggestionIndex >= filteredTags.length) {
|
||||
ghostText.textContent = "";
|
||||
return;
|
||||
}
|
||||
const selectedTag = filteredTags[tagSuggestionIndex]!;
|
||||
if (!selectedTag.toLowerCase().startsWith(partial.toLowerCase())) {
|
||||
ghostText.textContent = "";
|
||||
return;
|
||||
}
|
||||
const completion = selectedTag.substring(partial.length);
|
||||
ghostText.innerHTML = "";
|
||||
const invisible = document.createElement("span");
|
||||
invisible.style.visibility = "hidden";
|
||||
invisible.textContent = searchBar.value;
|
||||
ghostText.appendChild(invisible);
|
||||
ghostText.appendChild(document.createTextNode(completion));
|
||||
};
|
||||
|
||||
const updateTagDropdownHighlight = () => {
|
||||
const items = tagDropdown.querySelectorAll(".tag-suggestion-item");
|
||||
items.forEach((item, i) => {
|
||||
item.classList.toggle("active", i === tagSuggestionIndex);
|
||||
});
|
||||
const partial = getCurrentTagToken(searchBar.value) || "";
|
||||
updateGhostText(partial);
|
||||
};
|
||||
|
||||
const hideTagDropdown = () => {
|
||||
tagDropdownVisible = false;
|
||||
tagSuggestionIndex = -1;
|
||||
filteredTags = [];
|
||||
tagDropdown.style.display = "none";
|
||||
ghostText.textContent = "";
|
||||
};
|
||||
|
||||
const acceptTagSuggestion = (tag: string) => {
|
||||
const value = searchBar.value;
|
||||
const lastHashIndex = value.lastIndexOf("#");
|
||||
if (lastHashIndex !== -1) {
|
||||
searchBar.value = value.substring(0, lastHashIndex) + "#" + tag + " ";
|
||||
}
|
||||
hideTagDropdown();
|
||||
searchBar.focus();
|
||||
searchBar.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
||||
const showTagDropdown = (partial: string) => {
|
||||
if (!indexInitialized) return;
|
||||
filteredTags =
|
||||
partial === ""
|
||||
? allTags.slice(0, 10)
|
||||
: allTags.filter((t) => t.toLowerCase().startsWith(partial.toLowerCase())).slice(0, 10);
|
||||
if (filteredTags.length === 0) {
|
||||
hideTagDropdown();
|
||||
return;
|
||||
}
|
||||
tagSuggestionIndex = 0;
|
||||
tagDropdownVisible = true;
|
||||
removeAllChildren(tagDropdown);
|
||||
for (let i = 0; i < filteredTags.length; i++) {
|
||||
const tag = filteredTags[i]!;
|
||||
const item = document.createElement("div");
|
||||
item.className = "tag-suggestion-item" + (i === 0 ? " active" : "");
|
||||
item.setAttribute("role", "option");
|
||||
item.setAttribute("data-tag", tag);
|
||||
item.setAttribute("data-index", String(i));
|
||||
item.textContent = "#" + tag;
|
||||
tagDropdown.appendChild(item);
|
||||
}
|
||||
tagDropdown.style.display = "block";
|
||||
updateGhostText(partial);
|
||||
};
|
||||
|
||||
const navigateTagDropdown = (direction: "up" | "down"): boolean => {
|
||||
if (!tagDropdownVisible || filteredTags.length === 0) return false;
|
||||
if (direction === "down") {
|
||||
tagSuggestionIndex = Math.min(tagSuggestionIndex + 1, filteredTags.length - 1);
|
||||
} else {
|
||||
tagSuggestionIndex = Math.max(tagSuggestionIndex - 1, 0);
|
||||
}
|
||||
updateTagDropdownHighlight();
|
||||
return true;
|
||||
};
|
||||
|
||||
let currentHover: HTMLElement | null = null;
|
||||
let previewToken = 0;
|
||||
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const hideSearch = () => {
|
||||
console.log("[Search] hideSearch called, stack:", new Error().stack);
|
||||
container.classList.remove("active");
|
||||
searchButton.setAttribute("aria-expanded", "false");
|
||||
searchBar.value = "";
|
||||
removeAllChildren(results!);
|
||||
if (preview) removeAllChildren(preview);
|
||||
searchLayout.classList.remove("display-results");
|
||||
searchType = "basic";
|
||||
currentHover = null;
|
||||
hideTagDropdown();
|
||||
searchButton.focus();
|
||||
};
|
||||
|
||||
const showSearch = (type: SearchType) => {
|
||||
console.log("[Search] showSearch called, type:", type);
|
||||
searchType = type;
|
||||
container.classList.add("active");
|
||||
console.log("[Search] container.classList after add:", container.classList.toString());
|
||||
searchButton.setAttribute("aria-expanded", "true");
|
||||
searchBar.focus();
|
||||
console.log(
|
||||
"[Search] focus called, container active:",
|
||||
container.classList.contains("active"),
|
||||
);
|
||||
};
|
||||
|
||||
const displayResults = async (finalResults: any[]) => {
|
||||
removeAllChildren(results);
|
||||
|
||||
if (finalResults.length === 0) {
|
||||
results.innerHTML =
|
||||
'<a class="result-card no-match"><h3>No results.</h3><p>Try another search term?</p></a>';
|
||||
const noMatch = document.createElement("a");
|
||||
noMatch.className = "result-card no-match";
|
||||
const noMatchTitle = document.createElement("h3");
|
||||
noMatchTitle.textContent = "No results.";
|
||||
const noMatchHint = document.createElement("p");
|
||||
noMatchHint.textContent = "Try another search term?";
|
||||
noMatch.appendChild(noMatchTitle);
|
||||
noMatch.appendChild(noMatchHint);
|
||||
results.appendChild(noMatch);
|
||||
currentHover = null;
|
||||
if (preview) removeAllChildren(preview);
|
||||
} else {
|
||||
for (const item of finalResults) {
|
||||
const htmlTags =
|
||||
item.tags.length > 0 ? '<ul class="tags">' + item.tags.join("") + "</ul>" : "";
|
||||
const itemTile = document.createElement("a");
|
||||
itemTile.className = "result-card";
|
||||
itemTile.id = item.slug;
|
||||
itemTile.href = resolveBasePath(item.slug);
|
||||
itemTile.innerHTML =
|
||||
'<h3 class="card-title">' +
|
||||
item.title +
|
||||
"</h3>" +
|
||||
htmlTags +
|
||||
'<p class="card-description">' +
|
||||
item.content +
|
||||
"</p>";
|
||||
itemTile.addEventListener("click", (e) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
hideSearch();
|
||||
});
|
||||
itemTile.addEventListener("mouseenter", () => {
|
||||
setFocus(itemTile);
|
||||
});
|
||||
|
||||
const titleEl = document.createElement("h3");
|
||||
titleEl.className = "card-title";
|
||||
titleEl.innerHTML = item.title;
|
||||
itemTile.appendChild(titleEl);
|
||||
|
||||
if (item.tags.length > 0) {
|
||||
const tagList = document.createElement("ul");
|
||||
tagList.className = "tags";
|
||||
tagList.innerHTML = item.tags.join("");
|
||||
itemTile.appendChild(tagList);
|
||||
}
|
||||
|
||||
const descEl = document.createElement("p");
|
||||
descEl.className = "card-description";
|
||||
descEl.innerHTML = item.content;
|
||||
itemTile.appendChild(descEl);
|
||||
|
||||
results.appendChild(itemTile);
|
||||
}
|
||||
}
|
||||
|
|
@ -205,7 +347,8 @@ async function setupSearch() {
|
|||
};
|
||||
|
||||
const highlightTerm = () => {
|
||||
return searchType === "tags" ? currentSearchTerm.substring(1).trim() : currentSearchTerm;
|
||||
const parsed = parseSearchQuery(currentSearchTerm);
|
||||
return parsed.query || (parsed.tags.length > 0 ? parsed.tags.join(" ") : currentSearchTerm);
|
||||
};
|
||||
|
||||
const updatePreview = async (el: HTMLElement | null) => {
|
||||
|
|
@ -217,12 +360,22 @@ async function setupSearch() {
|
|||
const contents = await fetchContent(slug);
|
||||
if (token !== previewToken) return;
|
||||
const term = highlightTerm();
|
||||
const previewInner = document.createElement("div");
|
||||
previewInner.className = "preview-inner";
|
||||
for (const contentEl of contents) {
|
||||
const cloned = contentEl.cloneNode(true) as HTMLElement;
|
||||
if (term.trim() !== "") {
|
||||
cloned.innerHTML = highlightHTML(term, cloned);
|
||||
}
|
||||
preview.appendChild(cloned);
|
||||
previewInner.appendChild(cloned);
|
||||
}
|
||||
preview.appendChild(previewInner);
|
||||
|
||||
const highlights = Array.from(preview.getElementsByClassName("highlight")).sort(
|
||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||
);
|
||||
if (highlights[0]) {
|
||||
highlights[0].scrollIntoView({ block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -230,7 +383,8 @@ async function setupSearch() {
|
|||
if (currentHover) currentHover.classList.remove("focus");
|
||||
currentHover = el;
|
||||
if (currentHover) currentHover.classList.add("focus");
|
||||
updatePreview(currentHover);
|
||||
if (previewDebounceTimer) clearTimeout(previewDebounceTimer);
|
||||
previewDebounceTimer = setTimeout(() => updatePreview(currentHover), 150);
|
||||
};
|
||||
|
||||
const focusByIndex = (index: number) => {
|
||||
|
|
@ -260,61 +414,74 @@ async function setupSearch() {
|
|||
};
|
||||
|
||||
const onType = async (e: Event) => {
|
||||
currentSearchTerm = (e.target as HTMLInputElement).value;
|
||||
searchLayout.classList.toggle("display-results", currentSearchTerm !== "");
|
||||
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic";
|
||||
const inputValue = (e.target as HTMLInputElement).value;
|
||||
currentSearchTerm = inputValue;
|
||||
|
||||
if (currentSearchTerm === "") {
|
||||
const partialTag = getCurrentTagToken(inputValue);
|
||||
if (partialTag !== null) {
|
||||
showTagDropdown(partialTag);
|
||||
} else {
|
||||
hideTagDropdown();
|
||||
}
|
||||
|
||||
const parsed = parseSearchQuery(inputValue);
|
||||
const hasContent = parsed.query !== "" || parsed.tags.length > 0;
|
||||
searchLayout.classList.toggle("display-results", hasContent);
|
||||
searchType = parsed.tags.length > 0 && !parsed.query ? "tags" : "basic";
|
||||
|
||||
if (!hasContent) {
|
||||
removeAllChildren(results);
|
||||
if (preview) removeAllChildren(preview);
|
||||
currentHover = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = currentSearchTerm;
|
||||
let searchResults: any[];
|
||||
|
||||
if (searchType === "tags") {
|
||||
const tagQuery = currentSearchTerm.substring(1).trim();
|
||||
if (parsed.query) {
|
||||
searchResults = await index.searchAsync({
|
||||
query: tagQuery,
|
||||
limit: numSearchResults,
|
||||
query: parsed.query,
|
||||
limit: parsed.tags.length > 0 ? 10000 : numSearchResults,
|
||||
index: ["title", "content"],
|
||||
});
|
||||
} else if (parsed.tags.length > 0) {
|
||||
searchResults = await index.searchAsync({
|
||||
query: parsed.tags[0],
|
||||
limit: 10000,
|
||||
index: ["tags"],
|
||||
});
|
||||
} else {
|
||||
searchResults = await index.searchAsync({
|
||||
query,
|
||||
limit: numSearchResults,
|
||||
index: ["title", "content"],
|
||||
});
|
||||
searchResults = [];
|
||||
}
|
||||
|
||||
const allIds = new Set<number>();
|
||||
for (const fieldResult of searchResults) {
|
||||
if (fieldResult && fieldResult.result) {
|
||||
for (const id of fieldResult.result) {
|
||||
allIds.add(id as number);
|
||||
}
|
||||
}
|
||||
}
|
||||
const getByField = (field: string): number[] => {
|
||||
const matched = searchResults.filter((x: any) => x.field === field);
|
||||
return matched.length === 0 ? [] : ([...matched[0].result] as number[]);
|
||||
};
|
||||
|
||||
const finalResults: any[] = [];
|
||||
allIds.forEach((id) => {
|
||||
finalResults.push(formatForDisplay(currentSearchTerm, id));
|
||||
const allIds: Set<number> = new Set(fieldPriority.flatMap((field) => getByField(field)));
|
||||
|
||||
const filteredIds = [...allIds].filter((id) => {
|
||||
if (parsed.tags.length === 0) return true;
|
||||
const slug = idDataMap[id];
|
||||
if (!slug) return false;
|
||||
const item = contentData?.[slug];
|
||||
if (!item) return false;
|
||||
const itemTags: string[] = item.tags || [];
|
||||
return parsed.tags.every((tag) =>
|
||||
itemTags.some((t) => t.toLowerCase() === tag.toLowerCase()),
|
||||
);
|
||||
});
|
||||
|
||||
const displayTerm =
|
||||
parsed.query || (parsed.tags.length > 0 ? parsed.tags.join(" ") : inputValue);
|
||||
const finalResults = filteredIds.map((id) => formatForDisplay(displayTerm, id));
|
||||
|
||||
await displayResults(finalResults.slice(0, numSearchResults));
|
||||
const resultElements = getResultElements();
|
||||
setFocus(resultElements[0] ?? null);
|
||||
};
|
||||
|
||||
const onButtonClick = (e: Event) => {
|
||||
console.log(
|
||||
"[Search] Button click event, target:",
|
||||
e.target,
|
||||
"currentTarget:",
|
||||
e.currentTarget,
|
||||
);
|
||||
e.stopPropagation();
|
||||
showSearch("basic");
|
||||
};
|
||||
|
|
@ -325,6 +492,38 @@ async function setupSearch() {
|
|||
addCleanup(() => searchBar.removeEventListener("input", onType));
|
||||
|
||||
const onSearchBarKeydown = (e: KeyboardEvent) => {
|
||||
if (tagDropdownVisible) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
navigateTagDropdown("down");
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
navigateTagDropdown("up");
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (tagSuggestionIndex >= 0 && tagSuggestionIndex < filteredTags.length) {
|
||||
acceptTagSuggestion(filteredTags[tagSuggestionIndex]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
if (tagSuggestionIndex >= 0 && tagSuggestionIndex < filteredTags.length) {
|
||||
e.preventDefault();
|
||||
acceptTagSuggestion(filteredTags[tagSuggestionIndex]!);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
hideTagDropdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
e.preventDefault();
|
||||
focusPrevious();
|
||||
|
|
@ -335,7 +534,7 @@ async function setupSearch() {
|
|||
focusNext();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
const focused = currentHover;
|
||||
if (focused instanceof HTMLAnchorElement) {
|
||||
hideSearch();
|
||||
|
|
@ -354,11 +553,56 @@ async function setupSearch() {
|
|||
e.preventDefault();
|
||||
showSearch("tags");
|
||||
searchBar.value = "#";
|
||||
searchBar.dispatchEvent(new Event("input"));
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onDocumentKeydown);
|
||||
addCleanup(() => document.removeEventListener("keydown", onDocumentKeydown));
|
||||
|
||||
const onResultsClick = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest(".result-card") as HTMLAnchorElement | null;
|
||||
if (!target || target.classList.contains("no-match")) return;
|
||||
if (e instanceof MouseEvent && (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) return;
|
||||
hideSearch();
|
||||
};
|
||||
const onResultsMouseover = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest(".result-card") as HTMLElement | null;
|
||||
if (!target || target.classList.contains("no-match")) return;
|
||||
setFocus(target);
|
||||
};
|
||||
results.addEventListener("click", onResultsClick);
|
||||
results.addEventListener("mouseover", onResultsMouseover);
|
||||
addCleanup(() => {
|
||||
results!.removeEventListener("click", onResultsClick);
|
||||
results!.removeEventListener("mouseover", onResultsMouseover);
|
||||
});
|
||||
|
||||
const onTagDropdownClick = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest(
|
||||
".tag-suggestion-item",
|
||||
) as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const tag = target.getAttribute("data-tag");
|
||||
if (tag) acceptTagSuggestion(tag);
|
||||
};
|
||||
const onTagDropdownMouseover = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest(
|
||||
".tag-suggestion-item",
|
||||
) as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const idx = target.getAttribute("data-index");
|
||||
if (idx !== null) {
|
||||
tagSuggestionIndex = parseInt(idx, 10);
|
||||
updateTagDropdownHighlight();
|
||||
}
|
||||
};
|
||||
tagDropdown.addEventListener("click", onTagDropdownClick);
|
||||
tagDropdown.addEventListener("mouseover", onTagDropdownMouseover);
|
||||
addCleanup(() => {
|
||||
tagDropdown.removeEventListener("click", onTagDropdownClick);
|
||||
tagDropdown.removeEventListener("mouseover", onTagDropdownMouseover);
|
||||
});
|
||||
|
||||
const cleanupEscapeHandler = registerEscapeHandler(container, hideSearch);
|
||||
addCleanup(cleanupEscapeHandler);
|
||||
}
|
||||
|
|
@ -419,7 +663,9 @@ function highlightHTML(searchTerm: string, el: HTMLElement): string {
|
|||
|
||||
function highlight(searchTerm: string, text: string, trim?: boolean): string {
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm);
|
||||
const tokenizedText = text.split(/\s+/).filter((t) => t !== "");
|
||||
let tokenizedText = escapeHTML(text)
|
||||
.split(/\s+/)
|
||||
.filter((t) => t !== "");
|
||||
|
||||
let startIndex = 0;
|
||||
let endIndex = tokenizedText.length - 1;
|
||||
|
|
@ -441,8 +687,9 @@ function highlight(searchTerm: string, text: string, trim?: boolean): string {
|
|||
}
|
||||
}
|
||||
|
||||
startIndex = Math.max(bestIndex - contextWindowWords / 2, 0);
|
||||
endIndex = Math.min(startIndex + contextWindowWords, tokenizedText.length - 1);
|
||||
startIndex = Math.max(bestIndex - contextWindowWords, 0);
|
||||
endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1);
|
||||
tokenizedText = tokenizedText.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
const slice = tokenizedText
|
||||
|
|
@ -464,14 +711,15 @@ function highlight(searchTerm: string, text: string, trim?: boolean): string {
|
|||
);
|
||||
}
|
||||
|
||||
function highlightTags(term: string, tags?: string[]): string[] {
|
||||
if (!tags || searchType !== "tags") return [];
|
||||
function highlightTags(searchTags: string[], tags?: string[]): string[] {
|
||||
if (!tags || tags.length === 0 || searchTags.length === 0) return [];
|
||||
return tags
|
||||
.map((tag) => {
|
||||
if (tag.toLowerCase().includes(term.toLowerCase())) {
|
||||
return `<li><p class="match-tag">#${tag}</p></li>`;
|
||||
const escaped = escapeHTML(tag);
|
||||
if (searchTags.some((st) => tag.toLowerCase().includes(st.toLowerCase()))) {
|
||||
return `<li><p class="match-tag">#${escaped}</p></li>`;
|
||||
} else {
|
||||
return `<li><p>#${tag}</p></li>`;
|
||||
return `<li><p>#${escaped}</p></li>`;
|
||||
}
|
||||
})
|
||||
.slice(0, numTagResults);
|
||||
|
|
@ -498,31 +746,42 @@ function formatForDisplay(term: string, id: number): any {
|
|||
tags: [],
|
||||
};
|
||||
}
|
||||
const parsed = parseSearchQuery(currentSearchTerm);
|
||||
return {
|
||||
id: id,
|
||||
slug: slug,
|
||||
title: searchType === "tags" ? data.title : highlight(term, data.title || ""),
|
||||
title:
|
||||
parsed.tags.length > 0 && !parsed.query
|
||||
? escapeHTML(data.title)
|
||||
: highlight(term, data.title || ""),
|
||||
content: highlight(term, data.content || "", true),
|
||||
tags: highlightTags(term.substring(1), data.tags),
|
||||
tags: highlightTags(parsed.tags, data.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async function fillDocument() {
|
||||
if (!contentData) return;
|
||||
let id = 0;
|
||||
const promises: Array<Promise<unknown>> = [];
|
||||
const tagSet = new Set<string>();
|
||||
for (const slug of Object.keys(contentData)) {
|
||||
const fileData = contentData[slug];
|
||||
if (!fileData) continue;
|
||||
idDataMap[id] = slug;
|
||||
await index.addAsync(id, {
|
||||
id: id,
|
||||
slug: slug,
|
||||
title: fileData.title || "",
|
||||
content: fileData.content || "",
|
||||
tags: fileData.tags || [],
|
||||
});
|
||||
for (const tag of fileData.tags || []) tagSet.add(tag);
|
||||
promises.push(
|
||||
index.addAsync(id, {
|
||||
id: id,
|
||||
slug: slug,
|
||||
title: fileData.title || "",
|
||||
content: fileData.content || "",
|
||||
tags: fileData.tags || [],
|
||||
}),
|
||||
);
|
||||
id++;
|
||||
}
|
||||
await Promise.all(promises);
|
||||
allTags = [...tagSet].sort();
|
||||
}
|
||||
|
||||
async function fetchContentIndex(): Promise<Record<string, Item>> {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
.search > .search-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
z-index: var(--search-z-index, 999);
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space > * {
|
||||
.search > .search-container > .search-space > *:not(.ghost-text):not(.tag-suggestions) {
|
||||
width: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--light);
|
||||
|
|
@ -152,6 +152,7 @@
|
|||
.search > .search-container > .search-space > .search-layout[data-preview] > .results-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 60vh;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -311,3 +312,61 @@
|
|||
.result-card.no-match {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space > .ghost-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em 1em;
|
||||
font-family: var(--bodyFont);
|
||||
font-size: 1.1em;
|
||||
color: var(--gray);
|
||||
opacity: 0.6;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
line-height: normal;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space > .tag-suggestions {
|
||||
position: absolute;
|
||||
top: calc(2.1em + 1em + 2px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: calc(var(--search-z-index, 999) + 1);
|
||||
background: var(--light);
|
||||
border: 1px solid var(--lightgray);
|
||||
border-radius: 5px;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(27, 33, 48, 0.1),
|
||||
0 2px 6px rgba(27, 33, 48, 0.08);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space > .tag-suggestions > .tag-suggestion-item {
|
||||
padding: 0.4em 1em;
|
||||
cursor: pointer;
|
||||
font-family: var(--bodyFont);
|
||||
font-size: 0.95em;
|
||||
color: var(--dark);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space > .tag-suggestions > .tag-suggestion-item:hover,
|
||||
.search > .search-container > .search-space > .tag-suggestions > .tag-suggestion-item.active {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
.search > .search-container > .search-space > .tag-suggestions > .tag-suggestion-item.active {
|
||||
color: var(--secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ export default {
|
|||
components: {
|
||||
search: {
|
||||
title: "Suche",
|
||||
searchBarPlaceholder: "Suche nach etwas",
|
||||
searchBarPlaceholder: "Suche nach etwas...",
|
||||
noResults: "Keine Ergebnisse.",
|
||||
noResultsHint: "Versuchen Sie einen anderen Suchbegriff?",
|
||||
tagFilterHint: "Nach Tag filtern",
|
||||
noTagsFound: "Keine passenden Tags",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ export default {
|
|||
components: {
|
||||
search: {
|
||||
title: "Search",
|
||||
searchBarPlaceholder: "Search for something",
|
||||
searchBarPlaceholder: "Search for something...",
|
||||
noResults: "No results.",
|
||||
noResultsHint: "Try another search term?",
|
||||
tagFilterHint: "Filter by tag",
|
||||
noTagsFound: "No matching tags",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ export default {
|
|||
components: {
|
||||
search: {
|
||||
title: "Buscar",
|
||||
searchBarPlaceholder: "Buscar algo",
|
||||
searchBarPlaceholder: "Buscar algo...",
|
||||
noResults: "Sin resultados.",
|
||||
noResultsHint: "\u00bfIntentar con otro t\u00e9rmino de b\u00fasqueda?",
|
||||
tagFilterHint: "Filtrar por etiqueta",
|
||||
noTagsFound: "No se encontraron etiquetas",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ export default {
|
|||
components: {
|
||||
search: {
|
||||
title: "Recherche",
|
||||
searchBarPlaceholder: "Rechercher quelque chose",
|
||||
searchBarPlaceholder: "Rechercher quelque chose...",
|
||||
noResults: "Aucun r\u00e9sultat.",
|
||||
noResultsHint: "Essayez un autre terme de recherche\u00a0?",
|
||||
tagFilterHint: "Filtrer par tag",
|
||||
noTagsFound: "Aucun tag correspondant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
export default {
|
||||
components: {
|
||||
search: {
|
||||
title: "検索",
|
||||
searchBarPlaceholder: "何かを検索",
|
||||
title: "\u691c\u7d22",
|
||||
searchBarPlaceholder: "\u4f55\u304b\u3092\u691c\u7d22...",
|
||||
noResults: "\u7d50\u679c\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002",
|
||||
noResultsHint:
|
||||
"\u5225\u306e\u691c\u7d22\u8a9e\u3092\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002",
|
||||
tagFilterHint: "\u30bf\u30b0\u3067\u30d5\u30a3\u30eb\u30bf\u30fc",
|
||||
noTagsFound: "\u4e00\u81f4\u3059\u308b\u30bf\u30b0\u304c\u3042\u308a\u307e\u305b\u3093",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
export default {
|
||||
components: {
|
||||
search: {
|
||||
title: "搜索",
|
||||
searchBarPlaceholder: "搜索内容",
|
||||
title: "\u641c\u7d22",
|
||||
searchBarPlaceholder: "\u641c\u7d22\u5185\u5bb9...",
|
||||
noResults: "\u6ca1\u6709\u7ed3\u679c\u3002",
|
||||
noResultsHint: "\u8bf7\u5c1d\u8bd5\u5176\u4ed6\u641c\u7d22\u8bcd\u3002",
|
||||
tagFilterHint: "\u6309\u6807\u7b7e\u7b5b\u9009",
|
||||
noTagsFound: "\u6ca1\u6709\u5339\u914d\u7684\u6807\u7b7e",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { default as Search } from "./components/Search";
|
||||
export type { SearchOptions } from "./components/Search";
|
||||
export type { SearchOptions, SearchField } from "./components/Search";
|
||||
|
|
|
|||
Loading…
Reference in a new issue