tghandbook/src/ui/TabManager.ts

300 lines
8.4 KiB
TypeScript
Raw Normal View History

2020-06-17 13:16:54 +00:00
// @ts-expect-error: Asset imports are handled by parcel
2020-06-17 09:43:21 +00:00
import speen from "~/assets/images/speen.svg";
2020-06-26 11:59:13 +00:00
import { getPageHTML } from "../wiki";
import {
processHTML,
bindFunctions,
CURRENT_VERSION,
postProcessHTML,
} from "../scripts/index";
import cache from "../cache";
import { nextAnimationFrame, delay } from "../utils";
2020-06-17 09:43:21 +00:00
2020-06-19 18:26:43 +00:00
// @ts-expect-error: Parcel image import
import unknown from "~/assets/images/tab-icons/unknown.svg";
2020-06-17 09:43:21 +00:00
function initWaiting(elem: HTMLElement) {
// Add spinner
const spinnerContainer = document.createElement("div");
spinnerContainer.className = "speen";
const spinnerImg = document.createElement("img");
spinnerImg.src = speen;
spinnerContainer.appendChild(spinnerImg);
elem.appendChild(spinnerContainer);
}
async function loadPage(page: string, elem: HTMLElement): Promise<HTMLElement> {
2020-06-21 17:16:01 +00:00
let html: string | null = null;
const key = `page:${page}`;
// Check cache for pre-processed page
try {
const cachedPage = await cache.get<string>(key);
if (cachedPage) {
if (cachedPage.version === CURRENT_VERSION) {
console.log(`${page}: found cached entry`);
html = cachedPage.value;
} else {
console.log(`${page}: found outdated cache entry`);
}
}
} catch (e) {
console.log(`${page}: failed to retrieve cache entry:`, e);
}
// Fetch page content
2020-06-21 17:16:01 +00:00
if (!html) {
console.log(`${page}: fetching`);
let retries = 0;
while (retries < 5) {
try {
// eslint-disable-next-line no-await-in-loop
html = await getPageHTML(page);
break;
} catch (e) {
retries += 1;
// eslint-disable-next-line no-await-in-loop
await delay(1000);
}
}
2020-06-21 17:16:01 +00:00
// Convert relative links to absolute (and proxied)
html = html.replace(/"\/wiki/gi, '"//tgproxy.ovo.ovh/wiki');
2020-06-21 17:16:01 +00:00
await nextAnimationFrame();
// Set as HTML content and run HTML manipulations on it
const div = elem.cloneNode(false) as HTMLDivElement;
div.innerHTML = html;
2020-06-21 17:16:01 +00:00
2020-06-17 13:16:54 +00:00
console.log(`${page}: processing`);
processHTML(div, page);
2020-06-21 17:16:01 +00:00
// Save result to cache
cache.set(key, div.innerHTML, CURRENT_VERSION).then(() => {
2020-06-21 17:16:01 +00:00
console.log(`${page}: saved to cache`);
});
elem.replaceWith(div);
elem = div;
2020-06-21 17:16:01 +00:00
} else {
// Set cached content as HTML
elem.innerHTML = html;
2020-06-26 11:59:13 +00:00
postProcessHTML(elem, page); // noop in prod, used in dev for testing candidate DOM changes
2020-06-21 17:16:01 +00:00
}
bindFunctions(elem, page);
elem.classList.remove("waiting");
return elem;
2020-06-17 09:43:21 +00:00
}
2020-06-19 18:26:43 +00:00
type TabElements = {
tabListItem: HTMLElement;
tabContentItem: HTMLElement;
};
interface Section {
name: string;
element: HTMLElement;
tabs: Record<string, TabElements>;
}
2020-06-17 09:43:21 +00:00
export default class TabManager {
2020-06-24 15:12:11 +00:00
static instance: TabManager;
2020-06-19 18:26:43 +00:00
sectionListContainer: HTMLElement;
2020-06-17 09:43:21 +00:00
tabListContainer: HTMLElement;
2020-06-17 13:16:54 +00:00
2020-06-17 09:43:21 +00:00
tabContentContainer: HTMLElement;
2020-06-17 13:16:54 +00:00
2020-06-19 18:26:43 +00:00
sections: Record<string, Section> = {};
2020-06-17 09:43:21 +00:00
2020-06-24 15:12:11 +00:00
sectionMap: Record<string, string> = {};
loading: boolean;
2020-06-19 18:26:43 +00:00
constructor(
sectionlist: HTMLElement,
tablist: HTMLElement,
tabcontent: HTMLElement
) {
this.sectionListContainer = sectionlist;
2020-06-17 09:43:21 +00:00
this.tabListContainer = tablist;
this.tabContentContainer = tabcontent;
2020-06-24 15:12:11 +00:00
TabManager.instance = this;
2020-06-17 09:43:21 +00:00
}
/**
* Set app-wide loading state
* @param value is app still loading?
*/
setLoading(value: boolean): void {
if (value) {
document.getElementById("app").classList.add("waiting");
initWaiting(this.tabContentContainer);
const spinnerContainer = this.tabContentContainer.querySelector(".speen");
spinnerContainer.appendChild(
document.createTextNode("Loading wiki pages")
);
} else {
document.getElementById("app").classList.remove("waiting");
const elem = this.tabContentContainer.querySelector(".speen");
this.tabContentContainer.removeChild(elem);
}
}
2020-06-19 18:26:43 +00:00
/**
* Create section and add it to the section list
* @param name Section name
*/
createSection(name: string): void {
// Create section element
const sectionItem = document.createElement("div");
sectionItem.className = "section";
sectionItem.dataset.section = name;
sectionItem.appendChild(document.createTextNode(name));
sectionItem.addEventListener("click", () => {
if (sectionItem.classList.contains("active")) {
return;
}
this.showSection(name);
});
this.sectionListContainer.appendChild(sectionItem);
this.sections[name] = { name, element: sectionItem, tabs: {} };
}
/**
* Show tabs of a specific section
* @param name Section name
*/
showSection(name: string): void {
const active = this.sectionListContainer.querySelector<HTMLElement>(
".active"
);
if (active) {
// De-activate current section
active.classList.remove("active");
// Hide all tabs
this.tabListContainer
.querySelectorAll(`div[data-section=${active.dataset.section}]`)
.forEach((tab) => tab.classList.add("hidden"));
}
// Set section as active
this.sections[name].element.classList.add("active");
// Show all tabs of that section
this.tabListContainer
.querySelectorAll(`div[data-section=${name}]`)
.forEach((tab) => tab.classList.remove("hidden"));
}
/**
* Open tab page and add it to the tab list
* @param section Section to add the tab button to
* @param page Page name
* @param icon Icon to show
* @param setActive Also set the tab as active
*/
async openTab(
2020-06-19 18:26:43 +00:00
section: string,
page: string,
options: {
icon?: string;
active?: boolean;
text?: string;
}
): Promise<void> {
const { icon, active, text } = options;
2020-06-17 09:43:21 +00:00
// Create tab list item
const tabListItem = document.createElement("div");
tabListItem.className = "tab";
2020-06-19 18:26:43 +00:00
tabListItem.dataset.section = section;
2020-06-17 09:43:21 +00:00
tabListItem.dataset.tab = page;
tabListItem.addEventListener("click", () => {
if (tabListItem.classList.contains("active")) {
return;
}
2020-06-24 15:12:11 +00:00
this.setActive(page);
2020-06-17 09:43:21 +00:00
});
2020-06-19 18:26:43 +00:00
const iconElement = document.createElement("img");
iconElement.src = icon || unknown;
2020-06-19 18:26:43 +00:00
tabListItem.title = page.replace(/_/gi, " ");
tabListItem.appendChild(iconElement);
const shortTitle = text || page.substr(page.lastIndexOf("_") + 1, 4);
2020-06-19 18:26:43 +00:00
tabListItem.appendChild(document.createTextNode(shortTitle));
2020-06-17 09:43:21 +00:00
this.tabListContainer.appendChild(tabListItem);
// Create tab content container
const tabContentItem = document.createElement("div");
tabContentItem.className = "page waiting";
tabContentItem.dataset.tab = page;
initWaiting(tabContentItem);
2020-06-19 18:26:43 +00:00
this.tabContentContainer.appendChild(tabContentItem);
2020-06-17 09:43:21 +00:00
// Create tab entry
2020-06-19 18:26:43 +00:00
this.sections[section].tabs[page] = { tabListItem, tabContentItem };
2020-06-24 15:12:11 +00:00
this.sectionMap[page] = section;
2020-06-19 18:26:43 +00:00
// Hide tab if section is hidden
if (!this.sections[section].element.classList.contains("active")) {
tabListItem.classList.add("hidden");
}
2020-06-17 09:43:21 +00:00
// Start loading page for new tab
const elem = await loadPage(page, tabContentItem);
// Since element can be replaced (when loading for the first time), make sure the reference is updated
if (elem !== tabContentItem) {
this.sections[section].tabs[page].tabContentItem = elem;
}
2020-06-17 09:43:21 +00:00
// If asked for, set it to active
if (active) {
2020-06-24 15:12:11 +00:00
this.setActive(page);
2020-06-17 09:43:21 +00:00
}
}
2020-06-19 18:26:43 +00:00
/**
* Set a specific page to be the active/visible one
* @param page Page name
*/
2020-06-24 15:12:11 +00:00
setActive(page: string): void {
2020-06-17 09:43:21 +00:00
// Make sure tab exists (why wouldn't it?!)
2020-06-24 15:12:11 +00:00
const section = this.sectionMap[page];
2020-06-19 18:26:43 +00:00
if (!(section in this.sections)) {
throw new Error("section not found");
}
if (!(page in this.sections[section].tabs)) {
2020-06-17 09:43:21 +00:00
throw new Error("tab not found");
}
// Deactivate current active tab
this.tabListContainer
.querySelectorAll(".active")
.forEach((it) => it.classList.remove("active"));
this.tabContentContainer
.querySelectorAll(".active")
.forEach((it) => it.classList.remove("active"));
2020-06-19 18:26:43 +00:00
// If section is not shown, show it!
2020-06-24 15:12:11 +00:00
const isSectionActive = this.sections[section].element.classList.contains(
"active"
);
if (!isSectionActive) {
2020-06-19 18:26:43 +00:00
this.showSection(section);
}
2020-06-17 09:43:21 +00:00
// Activate new tab
2020-06-19 18:26:43 +00:00
const { tabListItem, tabContentItem } = this.sections[section].tabs[page];
this.sections[section].element.classList.add("active");
2020-06-17 09:43:21 +00:00
tabListItem.classList.add("active");
tabContentItem.classList.add("active");
}
}