SharedDocsLib/docslib/templates/client.js
2026-08-19 10:26:04 +00:00

925 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"use strict";
const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent);
const config = window.__DOCSLIB_CONFIG__ || JSON.parse(document.getElementById("docslib-config").textContent);
const mobileLayout = matchMedia("(max-width: 820px)");
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const elements = {
desktopTitle: document.getElementById("desktop-site-title"),
desktopSidebarToggle: document.getElementById("desktop-sidebar-toggle"),
mobileTitle: document.getElementById("mobile-site-title"),
desktopHeaderButtons: document.getElementById("desktop-header-buttons"),
mobileHeaderButtons: document.getElementById("mobile-header-buttons"),
desktopTree: document.getElementById("desktop-page-tree"),
mobileTree: document.getElementById("mobile-page-tree"),
toolbar: document.getElementById("desktop-toolbar"),
desktopSearchShell: document.getElementById("desktop-search-shell"),
desktopSearchInput: document.getElementById("desktop-search-input"),
desktopSearchButton: document.getElementById("desktop-search-button"),
desktopSearchResults: document.getElementById("desktop-search-results"),
mobileSearchInput: document.getElementById("mobile-search-input"),
mobileSearchButton: document.getElementById("mobile-search-button"),
mobileSearchResults: document.getElementById("mobile-search-results"),
content: document.getElementById("content"),
pageTransition: document.getElementById("page-transition"),
pageSurface: document.getElementById("page-surface"),
previous: document.getElementById("previous-page"),
next: document.getElementById("next-page"),
themeIcon: document.getElementById("theme-icon"),
mobileThemeButton: document.getElementById("mobile-theme-button"),
mobileThemeIcon: document.getElementById("mobile-theme-icon"),
refresh: document.getElementById("refresh-button"),
identity: document.getElementById("manifest-identity"),
mobileMenuButton: document.getElementById("mobile-menu-button"),
accessibilityButton: null,
mobileAccessibilityButton: null,
accessibilityView: null,
accessibilityBack: null,
accessibilitySizeValue: null,
accessibilitySizeDecrease: null,
accessibilitySizeIncrease: null,
accessibilityReset: null,
accessibilityFontOptions: null,
};
let manifest = window.__DOCSLIB_MANIFEST__ || embedded;
let currentNumber = null;
let syncing = false;
let mobileMode = "content";
let modeBeforeSearch = "content";
let placeholderTimer = 0;
let placeholderText = "";
let placeholderTarget = "";
let placeholderIndex = 0;
let placeholderDots = 0;
let placeholderPhase = "typing";
let lastPlaceholderPage = -1;
let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || "");
let pageTransitionId = 0;
let pageAnimation = null;
let skipNextPageAnimation = false;
let accessibilitySize = 16;
let accessibilityFont = "system-ui";
const accessibilityFonts = new Set(["system-ui", "sans-serif", "serif", "monospace"]);
const dbPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB"));
const request = indexedDB.open("docslib-cache", 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains("manifests")) {
request.result.createObjectStore("manifests");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
async function cachePut(value) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const transaction = db.transaction("manifests", "readwrite");
transaction.objectStore("manifests").put(value, config.cacheKey);
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error);
});
}
function manifestIdentity(value = manifest) {
const updated = String(value.updated_at || "")
.replace("T", " ")
.replace(/\.\d+(?=Z|[+-])/, "")
.replace("+00:00", "Z");
const revision = String(value.revision || "").slice(0, 8);
return [updated, revision].filter(Boolean).join(" · ") || "—";
}
function isManifest(value) {
return Boolean(
value &&
value.schema_version >= 2 &&
Array.isArray(value.pages) &&
value.client &&
value.client.css &&
typeof value.client.css.system === "string" &&
value.client.js &&
typeof value.client.js.system === "string"
);
}
function updateIdentity() {
elements.identity.textContent = manifestIdentity();
elements.refresh.title = String(manifest.revision || "");
}
function setSyncing(value) {
syncing = value;
elements.refresh.classList.toggle("loading", value);
}
function siblingResourceUrl(url, name) {
const target = new URL(url, document.baseURI);
target.pathname = target.pathname.replace(/[^/]*$/, name);
target.search = "";
target.hash = "";
return target.href;
}
function noCacheUrl(url) {
const target = new URL(url, document.baseURI);
target.searchParams.set(
"no_cache",
`${Date.now()}_${Math.random().toString(36).slice(2)}`,
);
return target.href;
}
function fetchNoCache(url) {
return fetch(noCacheUrl(url), { cache: "no-store" });
}
function routeNumber() {
const match = location.hash.match(/^#\/page\/(.+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
function isPlainClick(event) {
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
}
function setMobileMode(mode) {
mobileMode = mode;
document.body.classList.toggle("mobile-menu-open", mode === "menu");
document.body.classList.toggle("mobile-search-open", mode === "search");
}
function setDesktopSidebar(open) {
document.body.classList.toggle("desktop-sidebar-collapsed", !open);
elements.desktopSidebarToggle.setAttribute("aria-expanded", String(open));
}
function createAccessibilityButton(id, className) {
const button = document.createElement("button");
button.id = id;
button.className = `${className} accessibility-icon-button`;
button.type = "button";
button.setAttribute("aria-label", "Accessibility settings");
button.setAttribute("aria-controls", "accessibility-view");
button.setAttribute("aria-expanded", "false");
button.innerHTML = '<svg class="accessibility-glyph" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true"><path d="M423.5-743.5Q400-767 400-800t23.5-56.5Q447-880 480-880t56.5 23.5Q560-833 560-800t-23.5 56.5Q513-720 480-720t-56.5-23.5ZM360-80v-520H120v-80h720v80H600v520h-80v-240h-80v240h-80Z"></path></svg>';
return button;
}
function ensureAccessibilityUi() {
let launcher = document.getElementById("accessibility-button");
if (!launcher) {
launcher = createAccessibilityButton("accessibility-button", "accessibility-button");
document.body.append(launcher);
}
let mobileLauncher = document.getElementById("mobile-accessibility-button");
let mobileActions = document.querySelector(".mobile-preference-actions");
if (!mobileActions && elements.mobileTree) {
mobileActions = document.createElement("div");
mobileActions.className = "mobile-preference-actions";
elements.mobileTree.before(mobileActions);
}
if (mobileActions && elements.mobileThemeButton && elements.mobileThemeButton.parentElement !== mobileActions) {
mobileActions.append(elements.mobileThemeButton);
}
if (!mobileLauncher && mobileActions) {
mobileLauncher = createAccessibilityButton("mobile-accessibility-button", "mobile-accessibility-button");
mobileActions.append(mobileLauncher);
}
let view = document.getElementById("accessibility-view");
if (!view) {
view = document.createElement("section");
view.id = "accessibility-view";
view.className = "accessibility-view";
view.setAttribute("aria-labelledby", "accessibility-title");
view.hidden = true;
view.innerHTML = `
<button id="accessibility-back" class="accessibility-back" type="button" aria-label="Back"><span class="accessibility-back-arrow" aria-hidden="true"></span></button>
<h1 id="accessibility-title" class="accessibility-title">Accessibility</h1>
<div class="accessibility-size-row">
<div class="accessibility-size-control" role="group" aria-label="Text size">
<button id="accessibility-size-decrease" class="accessibility-step glyph-button" type="button" aria-label="Decrease text size"></button>
<output id="accessibility-size-value" aria-live="polite">16</output>
<button id="accessibility-size-increase" class="accessibility-step glyph-button" type="button" aria-label="Increase text size">+</button>
</div>
<button id="accessibility-reset" class="accessibility-reset glyph-button" type="button" aria-label="Reset accessibility settings">↻</button>
</div>
<div id="accessibility-font-options" class="accessibility-font-options" role="group" aria-label="Font family">
<button type="button" data-accessibility-font="system-ui" aria-pressed="true">system-ui</button>
<button type="button" data-accessibility-font="sans-serif" aria-pressed="false">sans-serif</button>
<button type="button" data-accessibility-font="serif" aria-pressed="false">serif</button>
<button type="button" data-accessibility-font="monospace" aria-pressed="false">monospace</button>
</div>
<article class="accessibility-preview" aria-label="Preview">
<h1>Header 1</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer vitae justo sed arcu posuere feugiat.</p>
<h2>Header 2</h2>
<p>Praesent commodo, sapien at aliquet posuere, neque erat feugiat mauris, vitae luctus orci lorem sed nulla.</p>
</article>`;
(elements.pageTransition || elements.pageSurface).append(view);
}
elements.accessibilityButton = launcher;
elements.mobileAccessibilityButton = mobileLauncher;
elements.accessibilityView = view;
elements.accessibilityBack = document.getElementById("accessibility-back");
elements.accessibilitySizeValue = document.getElementById("accessibility-size-value");
elements.accessibilitySizeDecrease = document.getElementById("accessibility-size-decrease");
elements.accessibilitySizeIncrease = document.getElementById("accessibility-size-increase");
elements.accessibilityReset = document.getElementById("accessibility-reset");
elements.accessibilityFontOptions = document.getElementById("accessibility-font-options");
}
function applyAccessibilityPreferences({ save = true } = {}) {
accessibilitySize = Math.min(28, Math.max(12, Number(accessibilitySize) || 16));
if (!accessibilityFonts.has(accessibilityFont)) accessibilityFont = "system-ui";
document.documentElement.style.setProperty("--accessibility-font-size", `${accessibilitySize}px`);
document.documentElement.style.setProperty("--accessibility-font-family", accessibilityFont);
if (elements.accessibilitySizeValue) elements.accessibilitySizeValue.value = String(accessibilitySize);
if (elements.accessibilitySizeDecrease) elements.accessibilitySizeDecrease.disabled = accessibilitySize <= 12;
if (elements.accessibilitySizeIncrease) elements.accessibilitySizeIncrease.disabled = accessibilitySize >= 28;
elements.accessibilityFontOptions?.querySelectorAll("[data-accessibility-font]").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.accessibilityFont === accessibilityFont));
});
if (!save) return;
try {
localStorage.setItem("docslib-accessibility-size", String(accessibilitySize));
localStorage.setItem("docslib-accessibility-font", accessibilityFont);
} catch (_) { /* preferences remain active for this page */ }
}
function loadAccessibilityPreferences() {
try {
accessibilitySize = Number(localStorage.getItem("docslib-accessibility-size")) || 16;
accessibilityFont = localStorage.getItem("docslib-accessibility-font") || "system-ui";
} catch (_) {
accessibilitySize = 16;
accessibilityFont = "system-ui";
}
applyAccessibilityPreferences({ save: false });
}
function setAccessibilityOpen(open, { restoreFocus = true } = {}) {
if (!elements.accessibilityView) return;
document.body.classList.toggle("accessibility-open", open);
elements.accessibilityView.hidden = !open;
elements.accessibilityButton?.setAttribute("aria-expanded", String(open));
elements.mobileAccessibilityButton?.setAttribute("aria-expanded", String(open));
if (open) {
closeSearch({ clear: true });
setMobileMode("content");
elements.pageSurface.scrollTo({ top: 0, behavior: "instant" });
requestAnimationFrame(() => elements.accessibilityBack?.focus());
} else if (restoreFocus) {
(mobileLayout.matches ? elements.mobileMenuButton : elements.accessibilityButton)?.focus();
}
}
function closeSearch({ clear = false } = {}) {
elements.desktopSearchShell.classList.remove("open");
if (clear) {
elements.desktopSearchInput.value = "";
elements.mobileSearchInput.value = "";
}
if (mobileMode === "search") setMobileMode(modeBeforeSearch);
}
function navigate(number, replace = false) {
const hash = `#/page/${encodeURIComponent(number)}`;
if (mobileLayout.matches && mobileMode === "menu") skipNextPageAnimation = true;
if (document.body.classList.contains("accessibility-open")) setAccessibilityOpen(false, { restoreFocus: false });
closeSearch({ clear: true });
setMobileMode("content");
if (replace) {
history.replaceState(null, "", hash);
renderPage(number);
} else if (location.hash !== hash) location.hash = hash;
else renderPage(number);
}
function buildTree(container) {
container.replaceChildren();
const root = { children: new Map(), page: null };
manifest.pages.forEach((page) => {
let node = root;
page.number.split(".").forEach((part) => {
if (!node.children.has(part)) node.children.set(part, { children: new Map(), page: null });
node = node.children.get(part);
});
node.page = page;
});
const appendNodes = (parent, node) => {
node.children.forEach((child) => {
const group = document.createElement("div");
group.className = "tree-group";
if (child.page) {
const button = document.createElement("button");
button.className = "tree-link";
button.dataset.page = child.page.number;
const number = document.createElement("span");
number.className = "tree-number";
number.textContent = `${child.page.number}.`;
const title = document.createElement("span");
title.className = "tree-title";
title.textContent = child.page.title;
button.append(number, title);
button.addEventListener("click", () => navigate(child.page.number));
group.append(button);
}
if (child.children.size) {
const nested = document.createElement("div");
nested.className = "tree-children";
appendNodes(nested, child);
group.append(nested);
}
parent.append(group);
});
};
appendNodes(container, root);
}
function buildHeaderButtons(container) {
container.replaceChildren();
const buttons = (manifest.ui && manifest.ui.header_buttons) || [];
buttons.forEach((button) => {
if (!button.label || (!button.href && !button.page)) return;
const link = document.createElement("a");
link.className = "header-link";
link.textContent = button.label;
if (button.page) {
link.href = `#/page/${encodeURIComponent(button.page)}`;
link.dataset.page = button.page;
link.addEventListener("click", (event) => {
if (!isPlainClick(event)) return;
event.preventDefault();
navigate(button.page);
});
} else {
link.href = button.href;
if (button.target === "_blank") {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
container.append(link);
});
}
function assetDataUri(name) {
const asset = manifest.assets && manifest.assets[name];
return asset ? `data:${asset.mime};base64,${asset.data}` : "";
}
function hydrateAssets(root = elements.content, { eager = false } = {}) {
root.querySelectorAll("[data-docslib-asset]").forEach((element) => {
const name = element.dataset.docslibAsset;
const uri = assetDataUri(name);
if (uri && element.tagName === "IMG") {
if (eager) element.loading = "eager";
element.src = uri;
}
else if (!uri) element.setAttribute("title", name);
});
}
function removeFullPrintDocument() {
document.body.classList.remove("docslib-print-all");
document.getElementById("docslib-full-print")?.remove();
}
function showPrintLoading() {
document.getElementById("docslib-print-loading")?.remove();
const overlay = document.createElement("div");
overlay.id = "docslib-print-loading";
overlay.className = "print-loading-overlay";
overlay.setAttribute("aria-hidden", "true");
const spinner = document.createElement("div");
spinner.className = "print-loading-spinner";
overlay.append(spinner);
document.body.append(overlay);
return overlay;
}
async function waitForPrintAssets(root) {
const images = [...root.querySelectorAll("img")];
await Promise.all(images.map(async (image) => {
if (!image.complete) {
await new Promise((resolve) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", resolve, { once: true });
});
}
if (typeof image.decode === "function" && image.naturalWidth > 0) {
await image.decode().catch(() => {});
}
}));
if (document.fonts && document.fonts.ready) await document.fonts.ready;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
async function PrintFullDocumentation() {
const overlay = showPrintLoading();
removeFullPrintDocument();
try {
const printable = document.createElement("div");
printable.id = "docslib-full-print";
printable.className = "full-print-document";
manifest.pages.forEach((page) => {
const article = document.createElement("article");
article.className = "content print-page";
article.dataset.page = page.number;
const heading = document.createElement("header");
heading.className = "print-page-heading";
const number = document.createElement("span");
number.className = "print-page-number";
number.textContent = page.number;
const title = document.createElement("h1");
title.textContent = page.title;
heading.append(number, title);
const body = document.createElement("div");
body.className = "print-page-content";
body.innerHTML = page.content || "";
article.append(heading, body);
hydrateAssets(article, { eager: true });
printable.append(article);
});
document.body.append(printable);
await waitForPrintAssets(printable);
document.body.classList.add("docslib-print-all");
window.print();
} catch (error) {
removeFullPrintDocument();
throw error;
} finally {
overlay.remove();
}
}
globalThis.PrintFullDocumentation = PrintFullDocumentation;
window.addEventListener("afterprint", removeFullPrintDocument);
function updateActiveNavigation(number) {
document.querySelectorAll(".tree-link[data-page]").forEach((button) => {
button.classList.toggle("active", button.dataset.page === number);
});
document.querySelectorAll(".header-link[data-page]").forEach((button) => {
const target = button.dataset.page;
button.classList.toggle("active", number === target || number.startsWith(`${target}.`));
});
}
function setupNavButton(button, page, direction) {
button.disabled = !page;
button.replaceChildren();
if (!page) return;
const arrow = document.createElement("span");
arrow.className = `nav-arrow nav-arrow-${direction}`;
arrow.setAttribute("aria-hidden", "true");
const title = document.createElement("span");
title.textContent = page.title;
if (direction === "previous") button.append(arrow, title);
else button.append(title, arrow);
button.onclick = () => navigate(page.number);
}
function applyPage(page, index) {
const previousPage = manifest.pages.find((candidate) => candidate.number === currentNumber) || null;
document.dispatchEvent(new CustomEvent("on_page_change", {
bubbles: true,
detail: {
page,
previousPage,
number: page.number,
title: page.title,
index,
},
}));
currentNumber = page.number;
if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`);
document.title = `${page.title} · ${manifest.title}`;
elements.content.innerHTML = page.content || "";
hydrateAssets();
elements.content.querySelectorAll("a[href^='#/page/']").forEach((link) => {
link.addEventListener("click", (event) => {
if (!isPlainClick(event)) return;
event.preventDefault();
navigate(decodeURIComponent(link.hash.slice(7)));
});
});
updateActiveNavigation(page.number);
setupNavButton(elements.previous, manifest.pages[index - 1], "previous");
setupNavButton(elements.next, manifest.pages[index + 1], "next");
elements.pageSurface.scrollTo({ top: 0, behavior: "instant" });
}
async function renderPage(number) {
if (!manifest.pages.length) {
elements.content.replaceChildren();
elements.previous.disabled = elements.next.disabled = true;
return;
}
let index = manifest.pages.findIndex((page) => page.number === number);
if (index < 0) index = 0;
const page = manifest.pages[index];
const skipAnimation = skipNextPageAnimation;
skipNextPageAnimation = false;
if (page.number === currentNumber && elements.content.childNodes.length) return;
const currentIndex = manifest.pages.findIndex((candidate) => candidate.number === currentNumber);
const direction = currentIndex >= 0 && index < currentIndex ? -1 : 1;
const transitionElement = elements.pageTransition || elements.content;
const animate = currentIndex >= 0 && !skipAnimation && !reducedMotion.matches && typeof transitionElement.animate === "function";
const transitionId = ++pageTransitionId;
pageAnimation?.cancel();
pageAnimation = null;
if (animate) {
const outgoing = transitionElement.animate(
[
{ opacity: 1, transform: "translateX(0)" },
{ opacity: 0, transform: `translateX(${-30 * direction}px)` },
],
{ duration: 150, easing: "ease-in", fill: "forwards" },
);
pageAnimation = outgoing;
await outgoing.finished.catch(() => {});
if (transitionId !== pageTransitionId) return;
outgoing.cancel();
}
applyPage(page, index);
if (!animate || transitionId !== pageTransitionId) return;
const incoming = transitionElement.animate(
[
{ opacity: 0, transform: `translateX(${30 * direction}px)` },
{ opacity: 1, transform: "translateX(0)" },
],
{ duration: 210, easing: "ease-out" },
);
pageAnimation = incoming;
incoming.finished.catch(() => {}).finally(() => {
if (pageAnimation === incoming) pageAnimation = null;
});
}
function plainText(html) {
const node = document.createElement("div");
node.innerHTML = html;
return (node.textContent || "").replace(/\s+/g, " ").trim();
}
function rankedPages(query) {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return [];
const tokens = needle.split(/\s+/).filter(Boolean);
return manifest.pages
.map((page, order) => {
const title = page.title.toLocaleLowerCase();
const number = page.number.toLocaleLowerCase();
const text = plainText(page.content);
const haystack = `${number} ${title} ${text.toLocaleLowerCase()}`;
let score = 0;
if (title === needle) score += 1000;
if (title.startsWith(needle)) score += 500;
if (title.includes(needle)) score += 300;
if (number === needle) score += 600;
if (haystack.includes(needle)) score += 120;
tokens.forEach((token) => {
if (title.startsWith(token)) score += 80;
else if (title.includes(token)) score += 45;
else if (haystack.includes(token)) score += 12;
});
return { page, text, score, order };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.order - b.order);
}
function renderSearchResults(container, query, limit) {
container.replaceChildren();
rankedPages(query).slice(0, limit).forEach(({ page, text }) => {
const button = document.createElement("button");
button.className = "search-result";
const title = document.createElement("strong");
title.textContent = `${page.number}. ${page.title}`;
const excerpt = document.createElement("span");
excerpt.textContent = text.slice(0, 110) || "—";
button.append(title, excerpt);
button.addEventListener("click", () => navigate(page.number));
container.append(button);
});
}
function handleDesktopSearch() {
const query = elements.desktopSearchInput.value;
renderSearchResults(elements.desktopSearchResults, query, 8);
elements.desktopSearchShell.classList.toggle("open", Boolean(query.trim()));
}
function handleMobileSearch() {
const query = elements.mobileSearchInput.value;
if (query.trim()) {
if (mobileMode !== "search") modeBeforeSearch = mobileMode;
setMobileMode("search");
renderSearchResults(elements.mobileSearchResults, query, 8);
} else if (mobileMode === "search") {
setMobileMode(modeBeforeSearch);
elements.mobileSearchResults.replaceChildren();
}
}
function choosePlaceholder() {
if (!manifest.pages.length) return "";
let index = Math.floor(Math.random() * manifest.pages.length);
if (manifest.pages.length > 1 && index === lastPlaceholderPage) index = (index + 1) % manifest.pages.length;
lastPlaceholderPage = index;
const words = manifest.pages[index].title.trim().split(/\s+/).filter(Boolean);
const count = Math.min(words.length, words.length > 2 && Math.random() > .5 ? 3 : 2);
return words.slice(0, Math.max(1, count)).join(" ");
}
function applyPlaceholder(value, dots = 0) {
const display = value ? `${value}${".".repeat(dots)}` : "";
elements.desktopSearchInput.placeholder = display;
elements.mobileSearchInput.placeholder = display;
}
function placeholderTick() {
clearTimeout(placeholderTimer);
if (elements.desktopSearchInput.value || elements.mobileSearchInput.value) {
placeholderTimer = setTimeout(placeholderTick, 400);
return;
}
if (!placeholderTarget) placeholderTarget = choosePlaceholder();
if (!placeholderTarget) return;
if (placeholderPhase === "typing") {
placeholderIndex += 1;
placeholderText = placeholderTarget.slice(0, placeholderIndex);
applyPlaceholder(placeholderText);
if (placeholderIndex >= placeholderTarget.length) {
placeholderPhase = "dots";
placeholderTimer = setTimeout(placeholderTick, 180);
} else placeholderTimer = setTimeout(placeholderTick, 95 + Math.random() * 90);
} else if (placeholderPhase === "dots") {
placeholderDots += 1;
applyPlaceholder(placeholderText, placeholderDots);
if (placeholderDots >= 3) {
placeholderPhase = "waiting";
placeholderTimer = setTimeout(placeholderTick, 5000);
} else placeholderTimer = setTimeout(placeholderTick, 220);
} else if (placeholderPhase === "waiting") {
placeholderPhase = "erasing";
placeholderDots = 0;
applyPlaceholder(placeholderText);
placeholderTimer = setTimeout(placeholderTick, 180);
} else {
placeholderIndex -= 1;
placeholderText = placeholderTarget.slice(0, Math.max(0, placeholderIndex));
applyPlaceholder(placeholderText);
if (placeholderIndex <= 0) {
placeholderTarget = choosePlaceholder();
placeholderPhase = "typing";
placeholderTimer = setTimeout(placeholderTick, 420);
} else placeholderTimer = setTimeout(placeholderTick, 55);
}
}
function startPlaceholder() {
clearTimeout(placeholderTimer);
placeholderText = "";
placeholderTarget = choosePlaceholder();
placeholderIndex = 0;
placeholderDots = 0;
placeholderPhase = "typing";
if (reducedMotion.matches) {
applyPlaceholder(placeholderTarget, 3);
return;
}
placeholderTimer = setTimeout(placeholderTick, 500);
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const icon = theme === "dark" ? "☀" : "◐";
elements.themeIcon.textContent = icon;
if (elements.mobileThemeIcon) elements.mobileThemeIcon.textContent = icon;
localStorage.setItem("docslib-theme", theme);
}
function toggleTheme() {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
}
function renderAll() {
window.__DOCSLIB_MANIFEST__ = manifest;
document.documentElement.lang = "und";
elements.desktopTitle.textContent = manifest.title;
elements.mobileTitle.textContent = manifest.title;
buildHeaderButtons(elements.desktopHeaderButtons);
buildHeaderButtons(elements.mobileHeaderButtons);
buildTree(elements.desktopTree);
buildTree(elements.mobileTree);
renderPage(routeNumber() || currentNumber || (manifest.pages[0] && manifest.pages[0].number));
updateIdentity();
startPlaceholder();
}
function loadCustomScript() {
if (customScriptLoaded) return;
customScriptLoaded = true;
const source = manifest.client && manifest.client.js && manifest.client.js.custom;
if (!source || !source.trim()) return;
const script = document.createElement("script");
script.id = "docslib-custom-script";
script.textContent = `${source}\n//# sourceURL=docslib-custom.js`;
document.body.append(script);
}
function announceRendered() {
try {
loadCustomScript();
} catch (error) {
console.error("SharedDocsLib custom_js", error);
} finally {
document.dispatchEvent(new CustomEvent("docslib:rendered", { detail: { manifest } }));
}
}
async function announceManifestUpdate(nextManifest, source) {
const previousRevision = lastSyncedRevision;
let cached = false;
try {
await cachePut(nextManifest);
cached = true;
} catch (_) { /* the update event still reports an unavailable cache */ }
lastSyncedRevision = String(nextManifest.revision || lastSyncedRevision);
document.dispatchEvent(new CustomEvent("on_manifest_update", {
bubbles: true,
detail: {
manifest: nextManifest,
currentManifest: manifest,
revision: nextManifest.revision,
previousRevision,
source,
cached,
applied: false,
},
}));
}
async function syncManifest() {
if (syncing) return false;
setSyncing(true);
let changed = false;
try {
if (
isManifest(embedded) &&
embedded.schema_version === manifest.schema_version &&
String(embedded.updated_at || "") > String(manifest.updated_at || "") &&
embedded.revision !== lastSyncedRevision
) {
await announceManifestUpdate(embedded, "embedded");
changed = true;
}
const versionUrl = config.versionUrl || siblingResourceUrl(config.manifestUrl, "version");
const versionResponse = await fetchNoCache(versionUrl);
if (!versionResponse.ok) throw new Error(`version:${versionResponse.status}`);
const remoteRevision = (await versionResponse.text()).trim();
if (!remoteRevision) throw new Error("version:empty");
if (remoteRevision === lastSyncedRevision) {
elements.refresh.classList.remove("error");
updateIdentity();
return changed;
}
document.dispatchEvent(new CustomEvent("on_update_available", {
bubbles: true,
detail: {
revision: remoteRevision,
currentRevision: lastSyncedRevision,
versionUrl,
manifestUrl: config.manifestUrl,
},
}));
const response = await fetchNoCache(config.manifestUrl);
if (!response.ok) throw new Error(String(response.status));
const remote = await response.json();
if (!isManifest(remote) || remote.schema_version !== embedded.schema_version) throw new Error("manifest");
if (String(remote.revision || "") !== remoteRevision) throw new Error("version:mismatch");
const remoteChanged = remote.revision !== lastSyncedRevision;
if (remoteChanged) {
await announceManifestUpdate(remote, "remote");
changed = true;
}
elements.refresh.classList.remove("error");
updateIdentity();
return changed;
} catch (error) {
elements.refresh.classList.add("error");
elements.refresh.title = String(error);
return false;
} finally {
setSyncing(false);
}
}
async function pollLive() {
try {
const response = await fetch(config.liveUrl, { cache: "no-store" });
if (!response.ok) return;
const live = await response.json();
if (live.revision && live.revision !== lastSyncedRevision) {
const changed = await syncManifest();
if (changed) location.reload();
}
} catch (_) { /* local preview may stop */ }
}
async function start() {
applyTheme(localStorage.getItem("docslib-theme") || "dark");
loadAccessibilityPreferences();
renderAll();
announceRendered();
syncManifest();
if (config.livePreview) setInterval(pollLive, 1200);
}
ensureAccessibilityUi();
elements.desktopSearchInput.addEventListener("input", handleDesktopSearch);
elements.desktopSearchButton.addEventListener("click", () => elements.desktopSearchInput.focus());
elements.mobileSearchInput.addEventListener("input", handleMobileSearch);
elements.mobileSearchButton.addEventListener("click", () => elements.mobileSearchInput.focus());
elements.mobileMenuButton.addEventListener("click", () => {
elements.mobileSearchInput.value = "";
elements.mobileSearchResults.replaceChildren();
setMobileMode(mobileMode === "menu" ? "content" : "menu");
});
try {
elements.desktopSidebarToggle.addEventListener("click", () => {
setDesktopSidebar(document.body.classList.contains("desktop-sidebar-collapsed"));
});
} catch (_) { /* older compiled HTML may not include the sidebar toggle */ }
document.getElementById("theme-button").addEventListener("click", toggleTheme);
if (elements.mobileThemeButton) elements.mobileThemeButton.addEventListener("click", toggleTheme);
elements.accessibilityButton?.addEventListener("click", () => setAccessibilityOpen(true));
elements.mobileAccessibilityButton?.addEventListener("click", () => setAccessibilityOpen(true));
elements.accessibilityBack?.addEventListener("click", () => setAccessibilityOpen(false));
elements.accessibilitySizeDecrease?.addEventListener("click", () => {
accessibilitySize -= 2;
applyAccessibilityPreferences();
});
elements.accessibilitySizeIncrease?.addEventListener("click", () => {
accessibilitySize += 2;
applyAccessibilityPreferences();
});
elements.accessibilityReset?.addEventListener("click", () => {
accessibilitySize = 16;
accessibilityFont = "system-ui";
applyAccessibilityPreferences();
});
elements.accessibilityFontOptions?.addEventListener("click", (event) => {
const button = event.target.closest("[data-accessibility-font]");
if (!button || !elements.accessibilityFontOptions.contains(button)) return;
accessibilityFont = button.dataset.accessibilityFont;
applyAccessibilityPreferences();
});
elements.refresh.addEventListener("pointerenter", () => elements.toolbar.classList.add("refresh-expanded"));
elements.refresh.addEventListener("pointerleave", () => elements.toolbar.classList.remove("refresh-expanded"));
elements.refresh.addEventListener("focus", () => elements.toolbar.classList.add("refresh-expanded"));
elements.refresh.addEventListener("blur", () => elements.toolbar.classList.remove("refresh-expanded"));
elements.refresh.addEventListener("click", async () => {
if (syncing) return;
await syncManifest();
});
document.addEventListener("pointerdown", (event) => {
if (!elements.desktopSearchShell.contains(event.target)) elements.desktopSearchShell.classList.remove("open");
});
window.addEventListener("hashchange", () => renderPage(routeNumber()));
window.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
(mobileLayout.matches ? elements.mobileSearchInput : elements.desktopSearchInput).focus();
}
if (event.key === "Escape" && document.body.classList.contains("accessibility-open")) {
setAccessibilityOpen(false);
} else if (event.key === "Escape") closeSearch({ clear: true });
});
mobileLayout.addEventListener?.("change", () => {
closeSearch({ clear: true });
setMobileMode("content");
});
start();
})();