445 lines
14 KiB
JavaScript
445 lines
14 KiB
JavaScript
(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);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
})();
|