(function () { const realtimeCallbacks = { notification: [], }; window.__cairnquireRealtime = { on: function (event, cb) { if (realtimeCallbacks[event]) { realtimeCallbacks[event].push(cb); } }, off: function (event, cb) { if (realtimeCallbacks[event]) { realtimeCallbacks[event] = realtimeCallbacks[event].filter(function (c) { return c !== cb; }); } }, }; const notice = document.querySelector("[data-version-notice]"); const reload = document.querySelector("[data-version-reload]"); const offlineNotice = document.querySelector("[data-offline-notice]"); const cachedNotice = document.querySelector("[data-cached-notice]"); const documentShell = document.querySelector("[data-document-path][data-document-hash]"); const browser = document.querySelector(".miller-browser"); const isEditing = Boolean(document.querySelector("[data-editor-form]")); // Auto-scroll miller browser to show the rightmost (active) column function scrollBrowserToRight() { if (!browser) return; requestAnimationFrame(function () { // Only scroll if the rightmost column is not already visible var tolerance = 50; var maxScroll = browser.scrollWidth - browser.clientWidth; if (maxScroll <= 0) return; // everything fits, no scroll needed if (browser.scrollLeft >= maxScroll - tolerance) return; // already at right edge browser.scrollTo({ left: browser.scrollWidth, behavior: "smooth", }); }); } scrollBrowserToRight(); function initColumnPreviewExpansion() { if (!browser) { return; } let hoverTimer = null; let expandedColumn = null; function isCompressedColumn(column) { return ( column && column.matches(".miller-column:not(:first-child):not(:last-child)") ); } function collapseColumn(column) { if (!column) return; column.classList.remove("is-preview-expanded"); column.style.removeProperty("--expanded-column-width"); if (expandedColumn === column) { expandedColumn = null; } } function expandColumn(column) { if (!isCompressedColumn(column)) return; if (expandedColumn && expandedColumn !== column) { collapseColumn(expandedColumn); } const expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384); column.style.setProperty("--expanded-column-width", expandedWidth + "px"); column.classList.add("is-preview-expanded"); expandedColumn = column; requestAnimationFrame(function () { const leftEdge = column.offsetLeft; const rightEdge = leftEdge + expandedWidth; let targetLeft = browser.scrollLeft; if (leftEdge < browser.scrollLeft) { targetLeft = leftEdge - 8; } else if (rightEdge > browser.scrollLeft + browser.clientWidth) { targetLeft = rightEdge - browser.clientWidth + 8; } browser.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth", }); }); } function scheduleExpansion(column) { if (!isCompressedColumn(column)) return; clearTimeout(hoverTimer); hoverTimer = setTimeout(function () { expandColumn(column); }, 1000); } browser.querySelectorAll(".miller-column").forEach(function (column) { column.addEventListener("pointerenter", function () { scheduleExpansion(column); }); column.addEventListener("mouseenter", function () { scheduleExpansion(column); }); column.addEventListener("mouseover", function () { if (!isCompressedColumn(column)) return; if (expandedColumn === column) return; scheduleExpansion(column); }); column.addEventListener("pointerleave", function () { clearTimeout(hoverTimer); collapseColumn(column); }); column.addEventListener("mouseleave", function () { clearTimeout(hoverTimer); collapseColumn(column); }); }); } initColumnPreviewExpansion(); function updateCachedUI(isCached) { if (!cachedNotice) return; cachedNotice.hidden = !isCached; } function cacheCurrentPage() { if (!window.MDHubCache) return; const html = document.documentElement.outerHTML; const title = document.title; const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search); window.MDHubCache.cachePage(pagePath, html, title).catch(function () {}); } function registerServiceWorker() { if (!("serviceWorker" in navigator)) return; window.addEventListener("load", function () { navigator.serviceWorker.register("/sw.js").catch(function () {}); }); } registerServiceWorker(); updateCachedUI(Boolean(window.__MDHUB_OFFLINE_CACHED__)); function updateOfflineUI() { if (!offlineNotice) return; if (navigator.onLine) { offlineNotice.hidden = true; } else { offlineNotice.hidden = false; } } window.addEventListener("online", function () { updateOfflineUI(); fetchAndCacheDocuments(); }); window.addEventListener("offline", function () { updateOfflineUI(); restoreBrowserFromCache(); }); updateOfflineUI(); // Cache current page content if (documentShell && window.MDHubCache) { const path = documentShell.getAttribute("data-document-path"); const title = document.title; const html = documentShell.querySelector(".markdown-body")?.innerHTML; if (path && html) { window.MDHubCache.cacheContent(path, html, title).catch(function () {}); } } cacheCurrentPage(); // Fetch and cache documents function fetchAndCacheDocuments() { if (!window.MDHubCache) return; fetch("/api/documents") .then(function (res) { if (!res.ok) throw new Error("fetch failed"); return res.json(); }) .then(function (data) { if (data.documents) { window.MDHubCache.cacheDocuments(data.documents).catch(function () {}); } }) .catch(function () {}); } fetchAndCacheDocuments(); // Restore browser from cache when offline function restoreBrowserFromCache() { if (!browser || !window.MDHubCache) return; window.MDHubCache.getCachedDocuments().then(function (docs) { if (!docs || docs.length === 0) return; // The browser is already rendered server-side; we just keep it. // If we wanted to rebuild it from cache, we'd need the full logic. // For now, the existing browser HTML is likely cached by the browser itself. }).catch(function () {}); } // Intercept browser navigation when offline if (browser) { browser.addEventListener("click", function (event) { const link = event.target.closest("a"); if (!link) return; if (navigator.onLine) return; const href = link.getAttribute("href"); if (!href || href.startsWith("http") || href.startsWith("//")) return; // Try to serve from cache for document pages if (href.startsWith("/docs/")) { event.preventDefault(); const path = href.replace("/docs/", ""); window.MDHubCache.getCachedContent(path).then(function (cached) { if (cached && cached.html) { loadCachedDocument(path, cached.title, cached.html); } else { window.location.href = href; } }).catch(function () { window.location.href = href; }); } }); } function loadCachedDocument(path, title, html) { if (!documentShell) { window.location.href = "/docs/" + path; return; } // Update URL without reloading history.pushState({ cachedPath: path }, title, "/docs/" + path); document.title = title; // Update document shell documentShell.setAttribute("data-document-path", path); documentShell.setAttribute("data-document-hash", ""); const titleEl = documentShell.querySelector(".document-meta h1"); if (titleEl) titleEl.textContent = title; const pathEl = documentShell.querySelector(".meta-grid code"); if (pathEl) pathEl.textContent = path; const bodyEl = documentShell.querySelector(".markdown-body"); if (bodyEl) bodyEl.innerHTML = html; updateCachedUI(true); cacheCurrentPage(); // Re-render math and mermaid if (typeof renderMath === "function") renderMath(); if (typeof renderMermaid === "function") renderMermaid(); // Update active state in browser updateBrowserActiveState(path); } function updateBrowserActiveState(activePath) { if (!browser) return; browser.querySelectorAll("a.is-active").forEach(function (el) { el.classList.remove("is-active"); }); browser.querySelectorAll("a").forEach(function (link) { const href = link.getAttribute("href"); if (href === "/docs/" + activePath || href === "/?folder=" + activePath) { link.classList.add("is-active"); } }); } // WebSocket realtime updates if (!window.WebSocket) { return; } const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null; const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; let reconnectTimer = null; function connect() { const socket = new WebSocket(protocol + "//" + window.location.host + "/ws"); socket.addEventListener("open", function () { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } }); socket.addEventListener("message", function (event) { let payload; try { payload = JSON.parse(event.data); } catch { return; } if (payload.type === "notification") { realtimeCallbacks.notification.forEach(function (cb) { cb(payload.data); }); return; } if (payload.type !== "document_version" || !payload.data) { return; } const change = payload.data; if (isEditing && currentPath && change.path === currentPath) { return; } if (currentPath && change.path === currentPath && change.hash !== currentHash) { if (notice) notice.hidden = false; return; } if (browser && !documentShell) { if (notice) notice.hidden = false; } }); socket.addEventListener("close", function () { reconnectTimer = setTimeout(connect, 3000); }); socket.addEventListener("error", function () { socket.close(); }); } connect(); if (reload) { reload.addEventListener("click", function () { window.location.reload(); }); } // Handle back/forward buttons for cached documents window.addEventListener("popstate", function (event) { if (event.state && event.state.cachedPath && window.MDHubCache) { window.MDHubCache.getCachedContent(event.state.cachedPath).then(function (cached) { if (cached && cached.html) { loadCachedDocument(event.state.cachedPath, cached.title, cached.html); } }).catch(function () {}); } }); // Add folder button handlers if (browser) { browser.querySelectorAll(".miller-column-add-folder").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); var parentPath = button.getAttribute("data-folder-path"); var folderName = window.prompt("Folder name:"); if (!folderName || !folderName.trim()) return; folderName = folderName.trim().replace(/[\\/]/g, "-"); var docPath = parentPath ? parentPath + "/" + folderName + "/index.md" : folderName + "/index.md"; var displayPath = docPath.replace(/\.md$/, ""); fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "# " + folderName + "\n\n", baseHash: "" }), }).then(function (res) { if (res.ok) { return res.json().then(function (data) { var path = data.path || docPath; var urlPath = path.replace(/\.md$/, ""); window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/"); }); } else { return res.json().then(function (data) { throw new Error(data.error || "Failed to create folder"); }); } }).catch(function (err) { window.alert(err.message); }); }); }); browser.querySelectorAll(".miller-column-new-file").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); var parentPath = button.getAttribute("data-folder-path"); var fileName = window.prompt("File name:"); if (!fileName || !fileName.trim()) return; fileName = fileName.trim().replace(/[\\/]/g, "-"); if (!fileName.endsWith(".md")) fileName += ".md"; var docPath = parentPath ? parentPath + "/" + fileName : fileName; var displayPath = docPath.replace(/\.md$/, ""); fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: "# " + fileName.replace(/\.md$/, "") + "\n\n", baseHash: "" }), }).then(function (res) { if (res.ok) { return res.json().then(function (data) { var path = data.path || docPath; var urlPath = path.replace(/\.md$/, ""); window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/"); }); } else { return res.json().then(function (data) { throw new Error(data.error || "Failed to create file"); }); } }).catch(function (err) { window.alert(err.message); }); }); }); } })();