blob: dcef94e6309e9fd6edbd3b81603325dd8672e560 [file] [log] [blame]
import React, { useCallback, useEffect, useRef, useState, } from "react";
import clsx from "clsx";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";
import { useHistory, useLocation } from "@docusaurus/router";
import { translate } from "@docusaurus/Translate";
import { ReactContextError, useDocsPreferredVersion, } from "@docusaurus/theme-common";
import { useActivePlugin } from "@docusaurus/plugin-content-docs/client";
import { fetchIndexes } from "./fetchIndexes";
import { SearchSourceFactory } from "../../utils/SearchSourceFactory";
import { SuggestionTemplate } from "./SuggestionTemplate";
import { EmptyTemplate } from "./EmptyTemplate";
import { searchResultLimits, Mark, searchBarShortcut, searchBarShortcutHint, docsPluginIdForPreferredVersion, indexDocs, } from "../../utils/proxiedGenerated";
import LoadingRing from "../LoadingRing/LoadingRing";
import styles from "./SearchBar.module.css";
async function fetchAutoCompleteJS() {
const autoCompleteModule = await import("@easyops-cn/autocomplete.js");
const autoComplete = autoCompleteModule.default;
if (autoComplete.noConflict) {
// For webpack v5 since docusaurus v2.0.0-alpha.75
autoComplete.noConflict();
}
else if (autoCompleteModule.noConflict) {
// For webpack v4 before docusaurus v2.0.0-alpha.74
autoCompleteModule.noConflict();
}
return autoComplete;
}
const SEARCH_PARAM_HIGHLIGHT = "_highlight";
export default function SearchBar({ handleSearchBarToggle, }) {
const { siteConfig: { baseUrl }, } = useDocusaurusContext();
// It returns undefined for non-docs pages
const activePlugin = useActivePlugin();
let versionUrl = baseUrl;
// For non-docs pages while using plugin-content-docs with custom ids,
// this will throw an error of:
// > Docusaurus plugin global data not found for "docusaurus-plugin-content-docs" plugin with id "default".
// It seems that we can not get the correct id for non-docs pages.
try {
// The try-catch is a hack because useDocsPreferredVersion just throws an
// exception when versions are not used.
// The same hack is used in SearchPage.tsx
// eslint-disable-next-line react-hooks/rules-of-hooks
const { preferredVersion } = useDocsPreferredVersion(activePlugin?.pluginId ?? docsPluginIdForPreferredVersion);
if (preferredVersion && !preferredVersion.isLast) {
versionUrl = preferredVersion.path + "/";
}
}
catch (e) {
if (indexDocs) {
if (e instanceof ReactContextError) {
/* ignore, happens when website doesn't use versions */
}
else {
throw e;
}
}
}
const history = useHistory();
const location = useLocation();
const searchBarRef = useRef(null);
const indexState = useRef("empty"); // empty, loaded, done
// Should the input be focused after the index is loaded?
const focusAfterIndexLoaded = useRef(false);
const [loading, setLoading] = useState(false);
const [inputChanged, setInputChanged] = useState(false);
const [inputValue, setInputValue] = useState("");
const search = useRef(null);
const loadIndex = useCallback(async () => {
if (indexState.current !== "empty") {
// Do not load the index (again) if its already loaded or in the process of being loaded.
return;
}
indexState.current = "loading";
setLoading(true);
const [{ wrappedIndexes, zhDictionary }, autoComplete] = await Promise.all([
fetchIndexes(versionUrl),
fetchAutoCompleteJS(),
]);
search.current = autoComplete(searchBarRef.current, {
hint: false,
autoselect: true,
openOnFocus: true,
cssClasses: {
root: styles.searchBar,
noPrefix: true,
dropdownMenu: styles.dropdownMenu,
input: styles.input,
hint: styles.hint,
suggestions: styles.suggestions,
suggestion: styles.suggestion,
cursor: styles.cursor,
dataset: styles.dataset,
empty: styles.empty,
},
}, [
{
source: SearchSourceFactory(wrappedIndexes, zhDictionary, searchResultLimits),
templates: {
suggestion: SuggestionTemplate,
empty: EmptyTemplate,
footer: ({ query, isEmpty }) => {
if (isEmpty) {
return;
}
const a = document.createElement("a");
const url = `${baseUrl}search?q=${encodeURIComponent(query)}`;
a.href = url;
a.textContent = translate({
id: "theme.SearchBar.seeAll",
message: "See all results",
});
a.addEventListener("click", (e) => {
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
search.current.autocomplete.close();
history.push(url);
}
});
const div = document.createElement("div");
div.className = styles.hitFooter;
div.appendChild(a);
return div;
},
},
},
])
.on("autocomplete:selected", function (event, { document: { u, h }, tokens }) {
searchBarRef.current?.blur();
let url = u;
if (Mark && tokens.length > 0) {
const params = new URLSearchParams();
for (const token of tokens) {
params.append(SEARCH_PARAM_HIGHLIGHT, token);
}
url += `?${params.toString()}`;
}
if (h) {
url += h;
}
history.push(url);
})
.on("autocomplete:closed", () => {
searchBarRef.current?.blur();
});
indexState.current = "done";
setLoading(false);
if (focusAfterIndexLoaded.current) {
const input = searchBarRef.current;
if (input.value) {
search.current.autocomplete.open();
}
input.focus();
}
}, [baseUrl, versionUrl, history]);
useEffect(() => {
if (!Mark) {
return;
}
const keywords = ExecutionEnvironment.canUseDOM
? new URLSearchParams(location.search).getAll(SEARCH_PARAM_HIGHLIGHT)
: [];
// A workaround to fix an issue of highlighting in code blocks.
// See https://github.com/easyops-cn/docusaurus-search-local/issues/92
// Code blocks will be re-rendered after this `useEffect` ran.
// So we make the marking run after a macro task.
setTimeout(() => {
const root = document.querySelector("article");
if (!root) {
return;
}
const mark = new Mark(root);
mark.unmark();
if (keywords.length !== 0) {
mark.mark(keywords);
}
// Apply any keywords to the search input so that we can clear marks in case we loaded a page with a highlight in the url
setInputValue(keywords.join(" "));
search.current?.autocomplete.setVal(keywords.join(" "));
});
}, [location.search, location.pathname]);
const [focused, setFocused] = useState(false);
const onInputFocus = useCallback(() => {
focusAfterIndexLoaded.current = true;
loadIndex();
setFocused(true);
handleSearchBarToggle?.(true);
}, [handleSearchBarToggle, loadIndex]);
const onInputBlur = useCallback(() => {
setFocused(false);
handleSearchBarToggle?.(false);
}, [handleSearchBarToggle]);
const onInputMouseEnter = useCallback(() => {
loadIndex();
}, [loadIndex]);
const onInputChange = useCallback((event) => {
setInputValue(event.target.value);
if (event.target.value) {
setInputChanged(true);
}
}, []);
// Implement hint icons for the search shortcuts on mac and the rest operating systems.
const isMac = ExecutionEnvironment.canUseDOM
? /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform)
: false;
useEffect(() => {
if (!searchBarShortcut) {
return;
}
// Add shortcuts command/ctrl + K
const handleShortcut = (event) => {
if ((isMac ? event.metaKey : event.ctrlKey) && event.code === "KeyK") {
event.preventDefault();
searchBarRef.current?.focus();
onInputFocus();
}
};
document.addEventListener("keydown", handleShortcut);
return () => {
document.removeEventListener("keydown", handleShortcut);
};
}, [isMac, onInputFocus]);
const onClearSearch = useCallback(() => {
const params = new URLSearchParams(location.search);
params.delete(SEARCH_PARAM_HIGHLIGHT);
const paramsStr = params.toString();
const searchUrl = location.pathname +
(paramsStr != "" ? `?${paramsStr}` : "") +
location.hash;
if (searchUrl != location.pathname + location.search + location.hash) {
history.push(searchUrl);
}
// We always clear these here because in case no match was selected the above history push wont happen
setInputValue("");
search.current?.autocomplete.setVal("");
}, [location.pathname, location.search, location.hash, history]);
return (<div className={clsx("navbar__search", styles.searchBarContainer, {
[styles.searchIndexLoading]: loading && inputChanged,
[styles.focused]: focused,
})}>
<input placeholder={translate({
id: "theme.SearchBar.label",
message: "Search",
description: "The ARIA label and placeholder for search button",
})} aria-label="Search" className="navbar__search-input" onMouseEnter={onInputMouseEnter} onFocus={onInputFocus} onBlur={onInputBlur} onChange={onInputChange} ref={searchBarRef} value={inputValue}/>
<LoadingRing className={styles.searchBarLoadingRing}/>
{searchBarShortcut &&
searchBarShortcutHint &&
(inputValue !== "" ? (<button className={styles.searchClearButton} onClick={onClearSearch}>
</button>) : (<div className={styles.searchHintContainer}>
<kbd className={styles.searchHint}>{isMac ? "⌘" : "ctrl"}</kbd>
<kbd className={styles.searchHint}>K</kbd>
</div>))}
</div>);
}