SharedDocsLib/docslib/templates/client.js

600 lines
22 KiB
JavaScript

(() => {
"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"),
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"),
pageSurface: document.getElementById("page-surface"),
previous: document.getElementById("previous-page"),
next: document.getElementById("next-page"),
themeIcon: document.getElementById("theme-icon"),
refresh: document.getElementById("refresh-button"),
identity: document.getElementById("manifest-identity"),
mobileMenuButton: document.getElementById("mobile-menu-button"),
};
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 || "");
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 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 (replace) history.replaceState(null, "", hash);
else if (location.hash !== hash) location.hash = hash;
closeSearch({ clear: true });
setMobileMode("content");
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() {
elements.content.querySelectorAll("[data-docslib-asset]").forEach((element) => {
const name = element.dataset.docslibAsset;
const uri = assetDataUri(name);
if (uri && element.tagName === "IMG") element.src = uri;
else if (!uri) element.setAttribute("title", name);
});
}
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 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];
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" });
}
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;
elements.themeIcon.textContent = theme === "dark" ? "☀" : "◐";
localStorage.setItem("docslib-theme", theme);
}
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");
renderAll();
announceRendered();
syncManifest();
if (config.livePreview) setInterval(pollLive, 1200);
}
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");
});
document.getElementById("theme-button").addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
});
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") closeSearch({ clear: true });
});
mobileLayout.addEventListener?.("change", () => {
closeSearch({ clear: true });
setMobileMode("content");
});
start();
})();