Global search!

This commit is contained in:
Hamcha 2020-06-24 17:12:11 +02:00
parent 679c45aac9
commit f179b0805c
Signed by untrusted user: hamcha
GPG key ID: 41467804B19A3315
5 changed files with 142 additions and 67 deletions

View file

@ -96,6 +96,8 @@ interface Section {
} }
export default class TabManager { export default class TabManager {
static instance: TabManager;
sectionListContainer: HTMLElement; sectionListContainer: HTMLElement;
tabListContainer: HTMLElement; tabListContainer: HTMLElement;
@ -104,6 +106,8 @@ export default class TabManager {
sections: Record<string, Section> = {}; sections: Record<string, Section> = {};
sectionMap: Record<string, string> = {};
loading: boolean; loading: boolean;
constructor( constructor(
@ -114,6 +118,7 @@ export default class TabManager {
this.sectionListContainer = sectionlist; this.sectionListContainer = sectionlist;
this.tabListContainer = tablist; this.tabListContainer = tablist;
this.tabContentContainer = tabcontent; this.tabContentContainer = tabcontent;
TabManager.instance = this;
} }
/** /**
@ -208,7 +213,7 @@ export default class TabManager {
if (tabListItem.classList.contains("active")) { if (tabListItem.classList.contains("active")) {
return; return;
} }
this.setActive(section, page); this.setActive(page);
}); });
const iconElement = document.createElement("img"); const iconElement = document.createElement("img");
iconElement.src = icon || unknown; iconElement.src = icon || unknown;
@ -228,6 +233,7 @@ export default class TabManager {
// Create tab entry // Create tab entry
this.sections[section].tabs[page] = { tabListItem, tabContentItem }; this.sections[section].tabs[page] = { tabListItem, tabContentItem };
this.sectionMap[page] = section;
// Hide tab if section is hidden // Hide tab if section is hidden
if (!this.sections[section].element.classList.contains("active")) { if (!this.sections[section].element.classList.contains("active")) {
@ -243,17 +249,17 @@ export default class TabManager {
// If asked for, set it to active // If asked for, set it to active
if (active) { if (active) {
this.setActive(section, page); this.setActive(page);
} }
} }
/** /**
* Set a specific page to be the active/visible one * Set a specific page to be the active/visible one
* @param section Section name
* @param page Page name * @param page Page name
*/ */
setActive(section: string, page: string): void { setActive(page: string): void {
// Make sure tab exists (why wouldn't it?!) // Make sure tab exists (why wouldn't it?!)
const section = this.sectionMap[page];
if (!(section in this.sections)) { if (!(section in this.sections)) {
throw new Error("section not found"); throw new Error("section not found");
} }
@ -263,9 +269,6 @@ export default class TabManager {
} }
// Deactivate current active tab // Deactivate current active tab
this.sectionListContainer
.querySelectorAll(".active")
.forEach((it) => it.classList.remove("active"));
this.tabListContainer this.tabListContainer
.querySelectorAll(".active") .querySelectorAll(".active")
.forEach((it) => it.classList.remove("active")); .forEach((it) => it.classList.remove("active"));
@ -274,7 +277,10 @@ export default class TabManager {
.forEach((it) => it.classList.remove("active")); .forEach((it) => it.classList.remove("active"));
// If section is not shown, show it! // If section is not shown, show it!
if (!this.sections[section].element.classList.contains("active")) { const isSectionActive = this.sections[section].element.classList.contains(
"active"
);
if (!isSectionActive) {
this.showSection(section); this.showSection(section);
} }

View file

@ -1,6 +1,7 @@
import TabManager from "./TabManager"; import TabManager from "./TabManager";
import sections from "./sections"; import sections from "./sections";
import { nextAnimationFrame } from "./utils"; import { nextAnimationFrame } from "./utils";
import { searchBox } from "./search";
// @ts-expect-error: Parcel image import // @ts-expect-error: Parcel image import
import unknown from "~/assets/images/tab-icons/unknown.svg"; import unknown from "~/assets/images/tab-icons/unknown.svg";
@ -50,7 +51,7 @@ async function load() {
manager.setLoading(false); manager.setLoading(false);
// Set first page as active // Set first page as active
manager.setActive("Medical", "Guide_to_chemistry"); manager.setActive("Guide_to_chemistry");
}); });
} }
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
@ -66,3 +67,5 @@ if ("serviceWorker" in navigator) {
} }
load(); load();
document.body.appendChild(searchBox());

View file

@ -1,29 +1,51 @@
interface SearchOption { import { nextAnimationFrame } from "./utils";
import TabManager from "./TabManager";
interface SearchEntry {
page: string;
element: HTMLElement;
name: string;
id: number;
alignment: ScrollLogicalPosition; alignment: ScrollLogicalPosition;
} }
export function searchBox( const allEntries: SearchEntry[] = [];
el: HTMLElement[],
searchCandidate, /**
options: SearchOption = { * Add one or more entries to the global search database
alignment: "center", * @param entries Search entries to add
*/
export function registerSearchEntries(entries: SearchEntry[]): void {
allEntries.push(...entries);
} }
): HTMLElement {
export function searchBox(): HTMLElement {
// Fuzzy search box // Fuzzy search box
const resultList = document.createElement("ul"); const resultList = document.createElement("ul");
const searchBoxElem = document.createElement("div"); const searchBoxElem = document.createElement("div");
let selectedResult = null; let selectedResult = 0;
let results = []; let results = [];
const jumpTo = (id: number) => { let global = false;
el[id].scrollIntoView({
block: options.alignment, const jumpTo = (entry: SearchEntry) => {
// If page is different jump to that
if (global) {
const currentPage = document.querySelector<HTMLElement>(".page.active")
.dataset.tab;
if (currentPage !== entry.page) {
TabManager.instance.setActive(entry.page);
}
}
entry.element.scrollIntoView({
block: entry.alignment,
inline: "nearest", inline: "nearest",
behavior: "auto", behavior: "auto",
}); });
document document
.querySelectorAll("table.wikitable .bgus_fz_selected") .querySelectorAll("table.wikitable .bgus_fz_selected")
.forEach((sel) => sel.classList.remove("bgus_fz_selected")); .forEach((sel) => sel.classList.remove("bgus_fz_selected"));
el[id].parentElement.classList.add("bgus_fz_selected"); entry.element.parentElement.classList.add("bgus_fz_selected");
}; };
const setSelectedResult = (i) => { const setSelectedResult = (i) => {
@ -32,21 +54,39 @@ export function searchBox(
.querySelectorAll(".selected") .querySelectorAll(".selected")
.forEach((sel) => sel.classList.remove("selected")); .forEach((sel) => sel.classList.remove("selected"));
resultList.children[i].classList.add("selected"); resultList.children[i].classList.add("selected");
jumpTo(results[i].id); jumpTo(results[i]);
}; };
const search = (str) => { const search = async (str: string, currentPage: string) => {
if (!str) { if (!str || str.length < 1) {
return; return;
} }
// Check for special flags
let entries: SearchEntry[] = allEntries;
global = str[0] === ",";
// Unless we're doing a global search don't show entries for other pages
if (!global) {
entries = allEntries.filter((e) => e.page === currentPage);
} else {
// Remove prefix from string
str = str.substr(1);
}
// Re-check string lenght after prefix removal
if (str.length < 1) {
return;
}
const combinations = str const combinations = str
.split("") .split("")
.map((c) => (c.includes(["\\", "]", "^"]) ? `\\${c}` : c)) .map((c) => (["\\", "]", "^"].includes(c) ? `\\${c}` : c))
.join("])(.*?)(["); .join("])(.*?)([");
const regex = new RegExp(`^(.*?)([${combinations}])(.*?)$`, "i"); const regex = new RegExp(`^(.*?)([${combinations}])(.*?)$`, "i");
const arr = searchCandidate results = entries
.map((o) => { .map((o) => ({
o.matches = (o.str.match(regex) || []) ...o,
matches: (o.name.match(regex) || [])
.slice(1) .slice(1)
.reduce((list, group, i, or) => { .reduce((list, group, i, or) => {
// Initialize first placeholder (always empty) and first matching "sections" // Initialize first placeholder (always empty) and first matching "sections"
@ -62,10 +102,9 @@ export function searchBox(
list.push([group]); list.push([group]);
} }
return list; return list;
}, []) }, [] as string[][])
.map((cstr) => cstr.join("")); .map((cstr) => cstr.join("")),
return o; }))
})
// Strike non-matching rows // Strike non-matching rows
.filter((o) => o.matches.length > 0) .filter((o) => o.matches.length > 0)
.sort((oA, oB) => { .sort((oA, oB) => {
@ -96,16 +135,28 @@ export function searchBox(
// Make the search stable since ECMAScript doesn't mandate it // Make the search stable since ECMAScript doesn't mandate it
return iA - iB; return iA - iB;
}); });
results = arr;
window.requestAnimationFrame(() => { await nextAnimationFrame();
console.log(results);
resultList.innerHTML = ""; resultList.innerHTML = "";
arr.forEach(({ matches, id }) => { results.forEach((elem) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.innerHTML = matches elem.matches.forEach((match, i) => {
.map((c, i) => (i % 2 ? `<strong>${c}</strong>` : c)) const cont = document.createElement(i % 2 ? "strong" : "span");
.join(""); cont.appendChild(document.createTextNode(match));
li.appendChild(cont);
});
if (global) {
const source = document.createElement("span");
source.className = "source";
source.appendChild(
document.createTextNode(elem.page.replace(/_/g, " "))
);
li.appendChild(source);
}
li.addEventListener("click", () => { li.addEventListener("click", () => {
jumpTo(id); jumpTo(elem);
searchBoxElem.classList.add("bgus_hidden"); searchBoxElem.classList.add("bgus_hidden");
}); });
resultList.appendChild(li); resultList.appendChild(li);
@ -113,7 +164,6 @@ export function searchBox(
if (results.length > 0) { if (results.length > 0) {
setSelectedResult(0); setSelectedResult(0);
} }
});
}; };
// Create fuzzy search box // Create fuzzy search box
@ -128,7 +178,7 @@ export function searchBox(
return; return;
case 13: // Enter - Jump to first result and hide bar case 13: // Enter - Jump to first result and hide bar
if (results.length > 0) { if (results.length > 0) {
jumpTo(results[selectedResult].id); jumpTo(results[selectedResult]);
} }
searchBoxElem.classList.add("bgus_hidden"); searchBoxElem.classList.add("bgus_hidden");
return; return;
@ -144,7 +194,10 @@ export function searchBox(
return; return;
default: default:
if (sel.value !== oldValue) { if (sel.value !== oldValue) {
search(sel.value); const currentPage = document.querySelector<HTMLElement>(
".page.active"
);
search(sel.value, currentPage.dataset.tab);
oldValue = sel.value; oldValue = sel.value;
} }
} }
@ -178,4 +231,4 @@ export function searchBox(
return searchBoxElem; return searchBoxElem;
} }
export default searchBox; export default { searchBox, registerSearchEntries };

View file

@ -1,5 +1,5 @@
import { darken, ColorFmt, lighten } from "./darkmode"; import { darken, ColorFmt, lighten } from "./darkmode";
import searchBox from "./search"; import { registerSearchEntries } from "./search";
import { findParent } from "./utils"; import { findParent } from "./utils";
// This is used for cache busting when userscript changes significantly. // This is used for cache busting when userscript changes significantly.
@ -301,14 +301,18 @@ function chemistryScript(root: HTMLElement) {
"table.wikitable > tbody > tr:not(:first-child) > th" "table.wikitable > tbody > tr:not(:first-child) > th"
) )
); );
const name = el.map((elem) => registerSearchEntries(
elem.querySelector(".reagent-header").textContent.trim().replace("▮", "") el.map((element, id) => ({
page: "Guide_to_chemistry",
name: element
.querySelector(".reagent-header")
.textContent.trim()
.replace("▮", ""),
element,
alignment: "center",
id,
}))
); );
const box = searchBox(
el,
name.map((e, i) => ({ id: i, str: e }))
);
document.body.appendChild(box);
document.body.addEventListener("keydown", (ev) => { document.body.addEventListener("keydown", (ev) => {
if (ev.shiftKey) { if (ev.shiftKey) {
@ -353,19 +357,21 @@ function chemistryScript(root: HTMLElement) {
}); });
} }
function genericScript(root: HTMLElement) { function genericScript(root: HTMLElement, docname: string) {
const el = Array.from( const el = Array.from(
root.querySelectorAll<HTMLElement>("div.mw-headline-cont[id][data-name]") root.querySelectorAll<HTMLElement>("div.mw-headline-cont[id][data-name]")
); );
const name = el.map((elem: HTMLDivElement) => elem.dataset.name.trim());
// Init fuzzy search with headlines // Init fuzzy search with headlines
const box = searchBox( registerSearchEntries(
el, el.map((element: HTMLDivElement, id) => ({
name.map((e, i) => ({ id: i, str: e })), id,
{ alignment: "start" } page: docname,
name: element.dataset.name.trim(),
element,
alignment: "start",
}))
); );
root.appendChild(box);
} }
export function bindFunctions(root: HTMLElement, docname: string): void { export function bindFunctions(root: HTMLElement, docname: string): void {
@ -374,7 +380,7 @@ export function bindFunctions(root: HTMLElement, docname: string): void {
chemistryScript(root); chemistryScript(root);
break; break;
default: default:
genericScript(root); genericScript(root, docname);
break; break;
} }
} }

View file

@ -48,6 +48,13 @@
margin: 0; margin: 0;
padding: 5px; padding: 5px;
cursor: pointer; cursor: pointer;
.source {
color: #ccc;
font-size: 8pt;
display: block;
line-height: 1.4em;
}
} }
#bgus_fz_searchbox li:hover { #bgus_fz_searchbox li:hover {
background-color: rgba(100, 100, 100, 0.5); background-color: rgba(100, 100, 100, 0.5);