/* =========================================================
ZETTELKASTEN GARDEN script.js
========================================================= */
document.addEventListener('DOMContentLoaded', () => {
rearrangeDOM();
buildSidebar();
initNoteSystem();
initSidebarRail();
initBacklinkPanel();
initLinkPreviews();
initCopyButtons();
initFootnoteSidenotes();
initThemeToggle();
initCountdowns();
initTOCHighlighting();
initMediaFixes(document);
initReadingProgress();
hookSearchResults();
});
/* =========================================================
DOM REARRANGEMENT
========================================================= */
function rearrangeDOM() {
const body = document.body;
const preamble = document.getElementById('preamble');
const stackRoot = document.getElementById('stack-root');
const postamble = document.getElementById('postamble');
if (!stackRoot) return;
const content = stackRoot.querySelector('#content');
// Inject breadcrumb trail into header
const bannerHeader = preamble?.querySelector('.banner-header');
if (bannerHeader) {
const bannerLeft = bannerHeader.querySelector('.banner-left');
const bannerSearch = bannerHeader.querySelector('.banner-search');
const crumbTrail = el('div', { id: 'breadcrumb-trail', 'aria-label': 'Navigation history' });
if (bannerLeft && bannerSearch) {
bannerHeader.insertBefore(crumbTrail, bannerSearch);
} else {
bannerHeader.appendChild(crumbTrail);
}
}
const layoutBody = el('div', { id: 'layout-body' });
const sidebar = el('nav', { id: 'sidebar', 'aria-label': 'Navigation' });
const main = el('div', { id: 'main' });
const noteToolbar = el('div', { id: 'note-toolbar' });
const mainInner = el('div', { id: 'main-inner' });
const contentArea = el('div', { id: 'content-area' });
const backlinkPnl = el('div', { id: 'backlink-panel', role: 'complementary', 'aria-label': 'Backlinks' });
buildNoteToolbar(noteToolbar);
backlinkPnl.innerHTML = `
No backlinks to this note.
`;
if (content) {
const rootPane = el('div', { class: 'tab-content active', 'data-tab-id': 'tab-root' });
rootPane.appendChild(content);
contentArea.appendChild(rootPane);
}
mainInner.appendChild(contentArea);
mainInner.appendChild(backlinkPnl);
main.appendChild(noteToolbar);
main.appendChild(mainInner);
layoutBody.appendChild(sidebar);
layoutBody.appendChild(main);
stackRoot.remove();
const progress = el('div', { id: 'reading-progress' });
body.appendChild(progress);
if (preamble?.nextSibling) {
body.insertBefore(layoutBody, preamble.nextSibling);
} else {
body.appendChild(layoutBody);
}
if (postamble) body.appendChild(postamble);
}
function buildNoteToolbar(toolbar) {
toolbar.innerHTML = `
`;
}
/* =========================================================
SIDEBAR RAIL
========================================================= */
function initSidebarRail() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const railBtns = [
{ label: '\u2630', title: 'All notes', id: 'rail-tree', panel: 'tree' },
{ label: '\u2315', title: 'Search', id: 'rail-search', action: 'search' },
];
railBtns.forEach(({ label, title, id, panel, action }) => {
const btn = el('button', { class: 'rail-btn', id, title, 'aria-label': title });
btn.textContent = label;
sidebar.appendChild(btn);
btn.addEventListener('click', () => {
if (action === 'search') {
if (typeof window.openSearchModal === 'function') {
window.openSearchModal();
} else {
document.getElementById('search-box')?.focus();
}
return;
}
const isExpanded = sidebar.classList.contains('expanded');
const samePanel = sidebar.dataset.activePanel === panel;
if (isExpanded && samePanel) {
sidebar.classList.remove('expanded');
sidebar.dataset.activePanel = '';
btn.classList.remove('active');
} else {
sidebar.classList.add('expanded');
sidebar.dataset.activePanel = panel;
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
showSidebarPanel(panel);
}
});
});
// Mobile backdrop
let backdrop = document.getElementById('sidebar-backdrop');
if (!backdrop) {
backdrop = el('div', { id: 'sidebar-backdrop' });
document.body.appendChild(backdrop);
}
backdrop.addEventListener('click', () => {
sidebar.classList.remove('expanded', 'mobile-open');
backdrop.classList.remove('visible');
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
});
initSidebarToggle();
}
function showSidebarPanel(panel) {
const sidebar = document.getElementById('sidebar');
sidebar.querySelectorAll('.sidebar-panel').forEach(p => p.remove());
if (panel === 'tree') {
const panelEl = el('div', { class: 'sidebar-panel' });
panelEl.innerHTML = `
`;
sidebar.appendChild(panelEl);
panelEl.querySelector('.sidebar-close-btn').addEventListener('click', () => {
sidebar.classList.remove('expanded');
sidebar.dataset.activePanel = '';
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
});
buildFileTree(panelEl.querySelector('#file-tree'));
}
}
async function buildFileTree(container) {
const currentPath = location.pathname;
let treeData = null;
try {
const res = await fetch('/assets/sidebar-tree.json');
if (res.ok) {
const json = await res.json();
if (Array.isArray(json.tree)) treeData = json.tree;
}
} catch (_) {}
if (treeData) {
renderTree(treeData, container, currentPath);
return;
}
const groups = await buildGroupsFromSearchIndex();
if (!groups?.length) {
const empty = el('div', { class: 'tree-file' });
empty.style.cssText = 'opacity:.4;font-size:.7rem;padding:8px 14px;';
empty.textContent = 'no notes found';
container.appendChild(empty);
return;
}
const fallbackTree = groups.map(g => ({ label: g.label, children: [], files: g.files }));
renderTree(fallbackTree, container, currentPath);
}
function renderTree(nodes, container, currentPath) {
nodes.forEach(node => {
const folderEl = el('div', { class: 'tree-folder', 'data-folder': node.label });
const icon = el('span', { class: 'tree-icon' });
icon.textContent = '\u25BE ';
const labelSpan = el('span', { class: 'tree-folder-label' });
labelSpan.textContent = node.label;
folderEl.append(icon, labelSpan);
const childrenWrap = el('div', { class: 'tree-children' });
(node.files || []).forEach(f => {
const pathname = normalisePathname(f.url);
childrenWrap.appendChild(makeFileItem({ title: f.title, pathname }, currentPath));
});
if (node.children?.length) renderTree(node.children, childrenWrap, currentPath);
folderEl.addEventListener('click', () => {
const isOpen = childrenWrap.style.display !== 'none';
childrenWrap.style.display = isOpen ? 'none' : '';
icon.textContent = isOpen ? '\u25B8 ' : '\u25BE ';
});
container.appendChild(folderEl);
container.appendChild(childrenWrap);
});
}
async function buildGroupsFromSearchIndex() {
try {
const res = await fetch('/search-index.json');
if (!res.ok) return null;
const raw = await res.json();
const all = Array.isArray(raw) ? raw : raw.entries || raw.notes || [];
const files = all
.filter(e => e && (e.url || e.path || e.pathname))
.map(e => ({
title: e.title || filenameToTitle(e.url || e.path || e.pathname),
url: e.url || e.path || e.pathname,
}))
.sort((a, b) => a.title.localeCompare(b.title));
return files.length ? [{ label: 'all notes', files }] : null;
} catch (_) { return null; }
}
function makeFileItem({ title, pathname }, currentPath) {
const fileEl = el('div', {
class: 'tree-file' + (pathname === currentPath ? ' active' : ''),
'data-pathname': pathname,
});
const iconSpan = el('span', { class: 'tree-icon' });
iconSpan.textContent = '\u00B7 ';
fileEl.appendChild(iconSpan);
fileEl.appendChild(document.createTextNode(title));
fileEl.title = title;
fileEl.addEventListener('click', () => {
document.getElementById('sidebar')?.classList.remove('expanded');
openNote(pathname, title);
});
return fileEl;
}
/* =========================================================
NOTE SYSTEM
========================================================= */
const noteHistory = [];
let historyIndex = -1;
let activeNoteId = null;
function makeHistoryState(entry, hash = '') {
return {
notePathname: entry.pathname,
noteLabel: entry.label,
noteHash: hash || '',
isRoot: !!entry.isRoot,
};
}
function historyUrlFor(entry, hash = '') {
if (entry.isRoot) {
return location.pathname + location.search;
}
return entry.pathname + (hash || '');
}
function readHashState() {
const hash = location.hash.slice(1);
const state = { active: null };
if (!hash) return state;
hash.split('&').forEach(part => {
const [key, val] = part.split('=');
if (!val) return;
if (key === 'active') state.active = decodeURIComponent(val);
});
return state;
}
function writeHashState() {
const current = noteHistory[historyIndex];
if (!current || current.isRoot) {
history.replaceState(null, '', location.pathname + location.search);
return;
}
const newHash = '#active=' + encodeURIComponent(current.pathname);
history.replaceState(null, '', location.pathname + location.search + newHash);
}
function initNoteSystem() {
const rootPane = document.querySelector(".tab-content[data-tab-id='tab-root']");
if (!rootPane) return;
const titleEl = rootPane.querySelector('.title-section .title, h1.title, h1');
const label = titleEl?.textContent.trim() || document.title || 'Home';
const rootEntry = { id: 'tab-root', label, pathname: location.pathname, paneEl: rootPane, isRoot: true };
noteHistory.push(rootEntry);
historyIndex = 0;
activeNoteId = 'tab-root';
updateBreadcrumb();
updateToolbar();
initMediaFixes(rootPane);
if (window._gallerySetupVideo) window._gallerySetupVideo(rootPane);
if (window._gallerySetupAudio) window._gallerySetupAudio(rootPane);
// Register the initial entry with browser history
history.replaceState(
makeHistoryState(rootEntry),
'',
historyUrlFor(rootEntry)
);
// Intercept internal .html link clicks only
document.addEventListener('click', e => {
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
let url;
try { url = new URL(href, location.href); } catch { return; }
if (url.origin !== location.origin) return;
// Only intercept .html pages — let assets, PDFs, etc. through
if (!url.pathname.endsWith('.html')) return;
e.preventDefault();
const pathname = normalisePathname(url.pathname);
const hash = url.hash || '';
const label = link.textContent.trim() || filenameToTitle(pathname);
openNote(pathname, label, hash);
});
// Toolbar buttons
document.getElementById('tb-back')?.addEventListener('click', goBack);
document.getElementById('tb-forward')?.addEventListener('click', goForward);
document.getElementById('tb-backlinks')?.addEventListener('click', toggleBacklinkPanel);
document.getElementById('tb-toc')?.addEventListener('click', toggleTOC);
// Backlink panel close
document.getElementById('bp-close')?.addEventListener('click', () => {
document.getElementById('backlink-panel')?.classList.remove('open');
document.getElementById('tb-backlinks')?.classList.remove('active');
});
window.addEventListener('popstate', async (e) => {
const state = e.state;
// No state: fall back to root
if (!state || !state.notePathname) {
if (noteHistory[0]) activateNote(noteHistory[0].id, true);
return;
}
const pathname = normalisePathname(state.notePathname);
const label = state.noteLabel || filenameToTitle(pathname);
const hash = state.noteHash || '';
const existing = noteHistory.find(n => n.pathname === pathname);
if (existing) {
activateNote(existing.id, true);
if (hash) scrollToHash(existing.paneEl, hash);
return;
}
await openNote(pathname, label, hash, true, false);
});
}
async function openNote(pathname, label, hash = '', silent = false, pushBrowserState = true) {
console.log('Opening note:', pathname, { label, hash, silent });
// Normalise
pathname = normalisePathname(pathname);
// Already at this note?
const current = noteHistory[historyIndex];
if (current?.pathname === pathname) {
if (hash) scrollToHash(current.paneEl, hash);
return;
}
// Already exists in history?
const existing = noteHistory.find(n => n.pathname === pathname);
if (existing) {
historyIndex = noteHistory.indexOf(existing);
activateNote(existing.id, true);
if (hash) scrollToHash(existing.paneEl, hash);
if (pushBrowserState) {
history.pushState(
makeHistoryState(existing, hash),
'',
historyUrlFor(existing, hash)
);
}
return;
}
// Show loading indicator
const contentArea = document.getElementById('content-area');
const loadingPane = el('div', { class: 'tab-content active', 'data-loading': '1' });
loadingPane.innerHTML = 'Loading\u2026
';
contentArea?.appendChild(loadingPane);
// Hide all other panes while loading
noteHistory.forEach(n => n.paneEl.classList.remove('active'));
let contentEl;
let resolvedLabel = label || filenameToTitle(pathname);
try {
const res = await fetch(pathname);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const titleEl = doc.querySelector('.title-section .title, h1.title, h1');
if (titleEl) resolvedLabel = titleEl.textContent.trim();
// Prefer #content, else fall back gracefully
contentEl = doc.getElementById('content')
|| doc.querySelector('.stack-pane')
|| (() => {
const d = document.createElement('div');
d.id = 'content';
d.innerHTML = 'Note loaded but content element not found.
';
return d;
})();
// Decode mermaid entities
contentEl.querySelectorAll('.mermaid').forEach(m => {
m.innerHTML = m.innerHTML
.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
});
fixRelativeSrcs(contentEl, pathname);
} catch (err) {
console.error('Note fetch error:', err);
contentEl = document.createElement('div');
contentEl.id = 'content';
contentEl.innerHTML =
`` +
`Could not load ${escHtml(pathname)}: ${escHtml(err.message)}
`;
}
// Remove the temporary loading pane
loadingPane.remove();
const id = 'tab-' + Date.now();
const paneEl = el('div', { class: 'tab-content', 'data-tab-id': id });
paneEl.appendChild(contentEl);
contentArea?.appendChild(paneEl);
const entry = { id, label: resolvedLabel, pathname, paneEl, isRoot: false };
// Truncate forward history when branching
if (historyIndex < noteHistory.length - 1) {
noteHistory.slice(historyIndex + 1).forEach(n => {
if (!n.isRoot) n.paneEl.remove();
});
noteHistory.splice(historyIndex + 1);
}
noteHistory.push(entry);
historyIndex = noteHistory.length - 1;
activateNote(id, true);
if (hash) scrollToHash(paneEl, hash);
if (pushBrowserState) {
history.pushState(
makeHistoryState(entry, hash),
'',
historyUrlFor(entry, hash)
);
}
// Run mermaid on newly loaded diagrams
if (typeof mermaid !== 'undefined') {
const diagrams = paneEl.querySelectorAll('.mermaid:not([data-processed])');
if (diagrams.length) mermaid.run({ nodes: diagrams });
}
initCopyButtonsIn(paneEl);
initFootnoteSidenotesIn(paneEl);
initMediaFixes(paneEl);
initLinkPreviewsIn(paneEl);
const trySetup = () => {
if (window._gallerySetupVideo) window._gallerySetupVideo(paneEl);
if (window._gallerySetupAudio) window._gallerySetupAudio(paneEl);
};
if (window._gallerySetupVideo) { trySetup(); } else { setTimeout(trySetup, 200); }
updateSidebarActive(pathname);
}
function activateNote(id, skipBrowserWrite = true) {
activeNoteId = id;
const idx = noteHistory.findIndex(n => n.id === id);
if (idx !== -1) historyIndex = idx;
noteHistory.forEach(n => n.paneEl.classList.toggle('active', n.id === id));
const area = document.getElementById('content-area');
if (area) area.scrollTop = 0;
updateBreadcrumb();
updateToolbar();
// Refresh backlink panel if open
const panel = document.getElementById('backlink-panel');
if (panel?.classList.contains('open')) refreshBacklinkPanel();
updateSidebarActive(noteHistory[historyIndex]?.pathname);
}
function goBack() {
window.history.back();
}
function goForward() {
window.history.forward();
}
function updateBreadcrumb() {
const trail = document.getElementById('breadcrumb-trail');
if (!trail) return;
trail.innerHTML = '';
const start = Math.max(0, historyIndex - 2);
const slice = noteHistory.slice(start, historyIndex + 1);
slice.forEach((entry, i) => {
if (i > 0) {
const sep = el('span', { class: 'crumb-sep', 'aria-hidden': 'true' });
sep.textContent = '/';
trail.appendChild(sep);
}
const isActive = i === slice.length - 1;
const crumb = el('span', { class: 'crumb' + (isActive ? ' active' : '') });
if (isActive) crumb.setAttribute('aria-current', 'page');
crumb.textContent = entry.label;
crumb.title = entry.pathname;
if (!isActive) {
const targetIdx = start + i;
crumb.addEventListener('click', () => {
historyIndex = targetIdx;
activateNote(entry.id);
});
}
trail.appendChild(crumb);
});
}
function updateToolbar() {
const backBtn = document.getElementById('tb-back');
if (backBtn) {
const canGoBack = historyIndex > 0;
backBtn.disabled = !canGoBack;
backBtn.style.opacity = canGoBack ? '1' : '0.35';
}
const fwdBtn = document.getElementById('tb-forward');
if (fwdBtn) {
const canGoFwd = historyIndex < noteHistory.length - 1;
fwdBtn.disabled = !canGoFwd;
fwdBtn.style.opacity = canGoFwd ? '1' : '0.35';
}
const pos = document.getElementById('note-graph-pos');
if (pos) {
const current = noteHistory[historyIndex];
if (current) {
const name = current.pathname.split('/').pop().replace('.html', '');
// Strip the timestamp prefix from Org-Roam filenames
const clean = name.replace(/^\d{14}-/, '');
pos.textContent = clean.length > 24 ? clean.slice(0, 22) + '\u2026' : clean;
}
}
}
function toggleTOC() {
const active = noteHistory[historyIndex];
if (!active) return;
const toc = active.paneEl.querySelector('#table-of-contents');
if (!toc) return;
const hidden = toc.style.display === 'none';
toc.style.display = hidden ? '' : 'none';
document.getElementById('tb-toc')?.classList.toggle('active', hidden);
}
/* =========================================================
BACKLINK PANEL
========================================================= */
function initBacklinkPanel() {
// Panel built in rearrangeDOM; nothing else needed here
}
function toggleBacklinkPanel() {
const panel = document.getElementById('backlink-panel');
const btn = document.getElementById('tb-backlinks');
if (!panel) return;
const isOpen = panel.classList.toggle('open');
btn?.classList.toggle('active', isOpen);
if (isOpen) refreshBacklinkPanel();
}
async function refreshBacklinkPanel() {
const panel = document.getElementById('backlink-panel');
if (!panel?.classList.contains('open')) return;
const list = document.getElementById('bp-list');
const countEl = document.getElementById('bp-count');
const current = noteHistory[historyIndex];
if (!list || !current) return;
list.innerHTML = 'Loading\u2026
';
if (countEl) countEl.textContent = '\u2026';
try {
const res = await fetch('/search-index.json');
if (!res.ok) throw new Error('no index');
const raw = await res.json();
const all = Array.isArray(raw) ? raw : raw.entries || raw.notes || [];
// Match by both the bare filename and the full pathname
const currentFile = current.pathname.split('/').pop().replace(/\.html$/, '');
const currentPath = normalisePathname(decodeURIComponent(current.pathname));
const backlinks = all.filter(e => {
if (!e) return false;
const entryUrl = normalisePathname(decodeURIComponent(e.url || ''));
if (entryUrl === currentPath) return false;
const links = (e.links || []).map(l =>
normalisePathname(decodeURIComponent(l))
);
return links.some(l => l === currentPath);
});
if (countEl) countEl.textContent = String(backlinks.length);
if (!backlinks.length) {
list.innerHTML = 'No backlinks found for this note.
';
return;
}
console.log("Have backlinks for",{
currentPath,
currentFile,
sampleEntry: all[0]
});
list.innerHTML = '';
backlinks.forEach(bl => {
const pathname = normalisePathname(bl.url || bl.path || bl.pathname || '');
const title = bl.title || filenameToTitle(pathname);
const excerpt = (bl.content || bl.body || bl.text || '').slice(0, 140).trim();
const item = el('div', { class: 'bp-item' });
const titleDiv = el('div', { class: 'bp-item-title' });
titleDiv.textContent = title;
item.appendChild(titleDiv);
if (excerpt) {
const excDiv = el('div', { class: 'bp-item-excerpt' });
excDiv.textContent = excerpt + '\u2026';
item.appendChild(excDiv);
}
item.addEventListener('click', () => openNote(pathname, title));
list.appendChild(item);
});
} catch (_) {
list.innerHTML = 'Could not load backlinks.
';
if (countEl) countEl.textContent = '!';
}
}
/* =========================================================
LINK HOVER PREVIEWS
========================================================= */
let previewTimer = null;
const previewCache = {};
function initLinkPreviews() {
if (!document.getElementById('link-preview')) {
const card = el('div', { id: 'link-preview', role: 'tooltip', 'aria-hidden': 'true' });
card.innerHTML = `
`;
document.body.appendChild(card);
}
initLinkPreviewsIn(document);
}
function initLinkPreviewsIn(root) {
// Only attach previews to internal .html links, not external or asset links
root.querySelectorAll('a[href*=".html"]').forEach(link => {
if (link.dataset.previewInited) return;
// Skip links to external origins
try {
const url = new URL(link.getAttribute('href'), location.href);
if (url.origin !== location.origin) return;
} catch { return; }
link.dataset.previewInited = '1';
link.addEventListener('mouseenter', e => {
clearTimeout(previewTimer);
previewTimer = setTimeout(() => showPreview(link, e), 300);
});
link.addEventListener('mouseleave', () => {
clearTimeout(previewTimer);
hidePreview();
});
link.addEventListener('mousemove', e => {
const card = document.getElementById('link-preview');
if (card?.classList.contains('visible')) positionPreview(card, e);
});
});
}
async function showPreview(link, evt) {
const card = document.getElementById('link-preview');
if (!card) return;
const href = link.getAttribute('href');
let url;
try { url = new URL(href, location.href); } catch { return; }
if (url.origin !== location.origin) return;
const pathname = normalisePathname(url.pathname);
const titleEl = document.getElementById('preview-title');
const bodyEl = document.getElementById('preview-body');
titleEl.textContent = link.textContent.trim() || filenameToTitle(pathname);
bodyEl.textContent = 'Loading\u2026';
positionPreview(card, evt);
card.classList.add('visible');
card.setAttribute('aria-hidden', 'false');
if (previewCache[pathname]) {
const { title, body } = previewCache[pathname];
titleEl.textContent = title;
bodyEl.textContent = body || 'No preview available.';
return;
}
try {
const res = await fetch(pathname);
if (!res.ok) throw new Error('fetch failed');
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const h1 = doc.querySelector('.title-section .title, h1.title, h1');
const paras = [...doc.querySelectorAll('#content p')];
const title = h1?.textContent.trim() || filenameToTitle(pathname);
const body = paras.slice(0, 3).map(p => p.textContent.trim()).join(' ').slice(0, 280);
previewCache[pathname] = { title, body };
if (card.classList.contains('visible')) {
titleEl.textContent = title;
bodyEl.textContent = body || 'No preview available.';
}
} catch (_) {
if (card.classList.contains('visible')) bodyEl.textContent = 'Preview unavailable.';
}
}
function positionPreview(card, evt) {
const margin = 14;
const cw = card.offsetWidth || 300;
const ch = card.offsetHeight || 200;
let x = evt.clientX + margin;
let y = evt.clientY + margin;
if (x + cw > window.innerWidth - margin) x = evt.clientX - cw - margin;
if (y + ch > window.innerHeight - margin) y = evt.clientY - ch - margin;
card.style.left = x + 'px';
card.style.top = y + 'px';
}
function hidePreview() {
const card = document.getElementById('link-preview');
card?.classList.remove('visible');
card?.setAttribute('aria-hidden', 'true');
}
/* =========================================================
READING PROGRESS BAR
========================================================= */
function initReadingProgress() {
const bar = document.getElementById('reading-progress');
const area = document.getElementById('content-area');
if (!bar || !area) return;
area.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = area;
const pct = scrollHeight <= clientHeight
? 0
: (scrollTop / (scrollHeight - clientHeight)) * 100;
bar.style.width = pct + '%';
}, { passive: true });
}
/* =========================================================
SEARCH RESULTS HOOK
========================================================= */
function hookSearchResults() {
const resultsBox = document.getElementById('search-results');
const searchBox = document.getElementById('search-box');
if (!resultsBox) return;
resultsBox.addEventListener('mousedown', e => {
let item = e.target;
while (item && item !== resultsBox) {
const url =
item.dataset.url ||
item.dataset.href ||
item.dataset.path ||
item.querySelector?.('a')?.getAttribute('href') ||
(item.tagName === 'A' ? item.getAttribute('href') : null);
if (url) {
e.preventDefault();
e.stopPropagation();
resultsBox.innerHTML = '';
if (searchBox) searchBox.value = '';
window.closeSearchModal?.();
let resolved;
try { resolved = new URL(url, location.href); } catch { return; }
if (resolved.origin !== location.origin) { window.open(url, '_blank'); return; }
if (!resolved.pathname.endsWith('.html')) return;
const label = item.textContent.trim() || filenameToTitle(resolved.pathname);
openNote(resolved.pathname, label, resolved.hash);
return;
}
item = item.parentElement;
}
});
if (searchBox) {
searchBox.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const active = resultsBox.querySelector('.active, li:first-child, div:first-child');
if (!active) return;
const url =
active.dataset.url || active.dataset.href ||
active.querySelector?.('a')?.getAttribute('href');
if (!url) return;
resultsBox.innerHTML = '';
searchBox.value = '';
window.closeSearchModal?.();
let resolved;
try { resolved = new URL(url, location.href); } catch { return; }
openNote(resolved.pathname, active.textContent.trim(), resolved.hash);
});
}
}
/* =========================================================
SIDEBAR TOGGLE
========================================================= */
function initSidebarToggle() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
// Keyboard shortcut: Ctrl+\ toggles sidebar
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
const nowExpanded = sidebar.classList.toggle('expanded');
if (nowExpanded && !sidebar.dataset.activePanel) {
sidebar.dataset.activePanel = 'tree';
showSidebarPanel('tree');
sidebar.querySelector('#rail-tree')?.classList.add('active');
}
if (!nowExpanded) {
sidebar.dataset.activePanel = '';
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
}
}
});
// Touch swipe-to-open
let touchStartX = null;
document.addEventListener('touchstart', e => {
if (e.touches[0].clientX < 20) touchStartX = e.touches[0].clientX;
}, { passive: true });
document.addEventListener('touchend', e => {
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
touchStartX = null;
if (dx > 40) {
sidebar.classList.add('expanded', 'mobile-open');
document.getElementById('sidebar-backdrop')?.classList.add('visible');
}
}, { passive: true });
sidebar.addEventListener('touchstart', e => {
touchStartX = e.touches[0].clientX;
}, { passive: true });
sidebar.addEventListener('touchend', e => {
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
touchStartX = null;
if (dx < -40) {
sidebar.classList.remove('expanded', 'mobile-open');
document.getElementById('sidebar-backdrop')?.classList.remove('visible');
}
}, { passive: true });
}
/* =========================================================
SIDEBAR ACTIVE STATE
========================================================= */
function updateSidebarActive(pathname) {
document.querySelectorAll('.tree-file').forEach(f => {
f.classList.toggle('active', f.dataset.pathname === pathname);
});
}
/* =========================================================
MEDIA FIXES
========================================================= */
function initMediaFixes(root) {
root.querySelectorAll('audio').forEach(audio => {
audio.controls = true;
if (!audio.closest('.audio-player')) {
const wrapper = document.createElement('div');
wrapper.className = 'audio-player';
const src = audio.src || audio.querySelector?.('source')?.src || '';
const labelText = audio.dataset.label || audio.title
|| src.split('/').pop().replace(/\.[^.]+$/, '') || 'audio';
const labelEl = document.createElement('div');
labelEl.className = 'audio-label';
labelEl.textContent = labelText;
audio.parentNode.insertBefore(wrapper, audio);
wrapper.append(labelEl, audio);
}
});
root.querySelectorAll('video').forEach(video => {
video.controls = true;
video.style.cssText = 'max-width:100%;width:100%;height:auto;display:block;border-radius:6px;';
if (!video.closest('.video-container')) {
const wrapper = document.createElement('div');
wrapper.className = 'video-container';
video.parentNode.insertBefore(wrapper, video);
wrapper.appendChild(video);
}
if (!video.currentSrc) video.load();
});
}
function fixRelativeSrcs(root, fromPathname) {
const base = new URL(fromPathname, location.href).href.split('/').slice(0, -1).join('/') + '/';
const fix = (elmt, attr) => {
const val = elmt.getAttribute(attr);
if (!val || val.startsWith('http') || val.startsWith('//')
|| val.startsWith('data:') || val.startsWith('#') || val.startsWith('/')) return;
try { elmt.setAttribute(attr, new URL(val, base).href); } catch {}
};
root.querySelectorAll('img').forEach(e => fix(e, 'src'));
root.querySelectorAll('video').forEach(e => fix(e, 'src'));
root.querySelectorAll('audio').forEach(e => fix(e, 'src'));
root.querySelectorAll('source').forEach(e => fix(e, 'src'));
root.querySelectorAll('a').forEach(e => fix(e, 'href'));
}
/* =========================================================
COPY BUTTONS
========================================================= */
function initCopyButtons() { initCopyButtonsIn(document); }
function initCopyButtonsIn(root) {
root.querySelectorAll('pre.src').forEach(block => {
if (block.querySelector('.copy-btn')) return;
block.style.position = 'relative';
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'copy';
block.appendChild(btn);
btn.addEventListener('click', async () => {
const text = block.innerText.replace(btn.innerText, '').trim();
try {
await navigator.clipboard.writeText(text);
btn.textContent = 'copied';
} catch {
btn.textContent = 'failed';
}
setTimeout(() => { btn.textContent = 'copy'; }, 1600);
});
});
}
/* =========================================================
FOOTNOTE SIDENOTES
========================================================= */
function initFootnoteSidenotes() { initFootnoteSidenotesIn(document); }
function initFootnoteSidenotesIn(root) {
root.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => {
const sup = ref.closest('sup') || ref;
if (sup.nextElementSibling?.classList.contains('footnote-sidenote')) return;
const targetId = ref.getAttribute('href').slice(1);
const anchor = document.getElementById(targetId)
|| root.querySelector?.(`#${CSS.escape(targetId)}`);
if (!anchor) return;
const footdef = anchor.closest('.footdef') || anchor.parentElement;
if (!footdef) return;
let paras = footdef.querySelectorAll('p.footpara');
if (!paras.length) paras = footdef.querySelectorAll('.footpara');
let parts = [];
if (paras.length) {
const seen = new Set();
parts = [...paras].map(p => {
const txt = p.textContent.trim().replace(/\s+/g, ' ');
if (seen.has(txt)) return '';
seen.add(txt);
return p.innerHTML.trim();
}).filter(Boolean);
}
if (!parts.length) {
const clone = footdef.cloneNode(true);
clone.querySelectorAll('sup.footnum, a[role="doc-backlink"]').forEach(n => n.remove());
parts = [clone.innerHTML.trim()];
}
const sidenote = document.createElement('span');
sidenote.className = 'sidenote footnote-sidenote';
sidenote.dataset.fn = ref.textContent.trim();
sidenote.innerHTML = parts.join(' ');
sup.insertAdjacentElement('afterend', sidenote);
});
}
/* =========================================================
THEME TOGGLE
========================================================= */
function initThemeToggle() {
const root = document.documentElement;
const key = 'theme';
const saved = localStorage.getItem(key);
const initial = (saved === 'dark' || saved === 'light') ? saved : 'dark';
root.setAttribute('data-theme', initial);
const btn = document.getElementById('theme-toggle');
if (!btn) return;
const updateIcon = theme => {
// Moon for dark mode, sun for light mode
btn.textContent = theme === 'dark' ? '\u263D' : '\u2600';
btn.setAttribute('aria-label',
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
};
updateIcon(initial);
btn.addEventListener('click', () => {
const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem(key, next);
updateIcon(next);
});
}
/* =========================================================
COUNTDOWNS
========================================================= */
function initCountdowns() {
const els = document.querySelectorAll('time.countdown');
if (!els.length) return;
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
const render = el => {
const target = new Date(el.getAttribute('datetime'));
const label = el.dataset.label || '';
if (isNaN(target)) { el.textContent = ''; return; }
let diff = target - new Date();
if (diff <= 0) {
el.textContent = `${label ? label + ' ' : ''}today`;
el.classList.add('expired');
return;
}
const d = Math.floor(diff / 86400000); diff %= 86400000;
const h = Math.floor(diff / 3600000); diff %= 3600000;
const m = Math.floor(diff / 60000); diff %= 60000;
const s = Math.floor(diff / 1000);
const parts = [];
if (d) parts.push(plural(d, 'day'));
parts.push(`${h}h ${m}m ${s}s`);
el.textContent = `${label ? label + ' in: ' : ''}${parts.join(' ')}`;
};
const tick = () => els.forEach(render);
tick();
setInterval(tick, 1000);
}
/* =========================================================
TOC HIGHLIGHTING
========================================================= */
function initTOCHighlighting() {
// Scoped to the currently active pane
const getActiveTOC = () => {
const active = noteHistory[historyIndex];
if (!active) return null;
return active.paneEl.querySelector('#text-table-of-contents');
};
const toc = getActiveTOC() || document.querySelector('#text-table-of-contents');
if (!toc) return;
const links = [...toc.querySelectorAll('a[href^="#"]')];
if (!links.length) return;
const linkById = new Map();
links.forEach(a => {
const id = decodeURIComponent(a.hash.slice(1));
const elmt = document.getElementById(id);
if (elmt) linkById.set(id, a);
});
const headings = [...document.querySelectorAll('h2[id], h3[id], h4[id]')]
.filter(h => linkById.has(h.id));
const setActive = id => {
links.forEach(a => {
const active = a.hash === `#${id}`;
a.classList.toggle('is-active', active);
if (active) a.setAttribute('aria-current', 'true');
else a.removeAttribute('aria-current');
});
};
const headerOffset = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize);
const visible = new Map();
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const id = entry.target.id;
if (entry.isIntersecting) {
visible.set(id, entry.target.getBoundingClientRect().top - headerOffset);
} else {
visible.delete(id);
}
});
if (visible.size) {
const [id] = [...visible.entries()].sort((a, b) => Math.abs(a[1]) - Math.abs(b[1]))[0];
setActive(id);
}
}, { rootMargin: `-${headerOffset}px 0px -70% 0px`, threshold: [0, 0.01, 0.1] });
headings.forEach(h => observer.observe(h));
toc.addEventListener('click', e => {
const a = e.target.closest('a[href^="#"]');
if (!a) return;
const id = decodeURIComponent(a.hash.slice(1));
const elmt = document.getElementById(id);
if (!elmt) return;
e.preventDefault();
elmt.scrollIntoView({ behavior: 'smooth', block: 'start' });
elmt.setAttribute('tabindex', '-1');
elmt.focus({ preventScroll: true });
history.pushState(null, '', `#${id}`);
});
}
/* =========================================================
HELPERS
========================================================= */
function scrollToHash(paneEl, hash) {
if (!hash) return;
requestAnimationFrame(() => {
const id = hash.replace(/^#/, '');
const target = paneEl.querySelector(`#${CSS.escape(id)}`);
const area = document.getElementById('content-area');
if (target && area) area.scrollTop = target.offsetTop;
});
}
function el(tag, attrs = {}) {
const e = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (v !== undefined) e.setAttribute(k, v);
});
return e;
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function filenameToTitle(pathname) {
const base = (pathname || '').split('/').pop().replace(/\.html$/, '');
// Strip Org-Roam timestamp prefix (14 digits)
const clean = base.replace(/^\d{14}-/, '');
return clean.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function normalisePathname(p) {
if (!p) return '';
if (p.startsWith('/')) return p;
try { return new URL(p, location.href).pathname; } catch { return '/' + p; }
}
// Lazy sidebar builder stub (built on demand)
async function buildSidebar() {}
// Pane-out animation keyframe
const _s = document.createElement('style');
_s.textContent = `
@keyframes pane-out {
from { opacity:1; transform:translateX(0); }
to { opacity:0; transform:translateX(16px); }
}
.note-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 40vh;
color: var(--fg-faint);
font-family: var(--font-mono);
font-size: 0.85rem;
letter-spacing: 0.06em;
opacity: 0.6;
}
`;
document.head.appendChild(_s);