Files
org_roam/assets/scripts/script.js
Zaine ac1d3839ba
Some checks failed
Build Roam Site / build (push) Has been cancelled
archiving
2026-06-03 14:12:58 +01:00

1564 lines
50 KiB
JavaScript
Executable File

/* =========================================================
ZETTELKASTEN GARDEN script.js
========================================================= */
document.addEventListener('DOMContentLoaded', () => {
rearrangeDOM();
buildSidebar();
initNoteSystem();
initSidebarRail();
initBacklinkPanel();
initLinkPreviews();
initCopyButtons();
initFootnoteSidenotes();
initThemeToggle();
initCountdowns();
initTOCHighlighting();
initMediaFixes(document);
initReadingProgress();
hookSearchResults();
initHomeDashboard();
});
/* =========================================================
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 = `
<div class="bp-header">
<span class="bp-label">backlinks</span>
<button class="bp-close" id="bp-close" title="Close backlinks" aria-label="Close backlinks">\u00D7</button>
</div>
<div class="bp-list" id="bp-list">
<p class="bp-empty">No backlinks to this note.</p>
</div>
`;
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 = `
<button class="toolbar-btn toolbar-btn-icon" id="tb-back" title="Go back" aria-label="Go back">\u2190</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-forward" title="Go forward" aria-label="Go forward">\u2192</button>
<div class="toolbar-sep"></div>
<button class="toolbar-btn toolbar-btn-icon" id="tb-toc" title="Toggle table of contents" aria-label="Toggle table of contents">\u2261</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-backlinks" title="Toggle backlinks panel" aria-label="Toggle backlinks panel">\u29C9</button>
<div class="toolbar-spacer"></div>
<span class="note-graph-pos" id="note-graph-pos"></span>
<button class="toolbar-btn toolbar-btn-icon" id="tb-close-all" title="Close all notes" aria-label="Close all notes">\u2715</button>
`;
}
/* =========================================================
SIDEBAR RAIL
========================================================= */
const RECENT_KEY = 'zg-recent-notes';
const RECENT_MAX = 12;
const FOLDER_STATE_KEY = 'zg-folder-state';
let sidebarDataCache = null;
async function fetchSidebarData() {
if (sidebarDataCache) return sidebarDataCache;
try {
const res = await fetch(`/assets/sidebar-tree.json?v=${Date.now()}`, { cache: 'no-store' });
if (res.ok) sidebarDataCache = await res.json();
} catch (_) {}
return sidebarDataCache || { mocs: [], tree: [] };
}
function closeSidebar() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
sidebar.classList.remove('expanded', 'mobile-open');
sidebar.dataset.activePanel = '';
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
document.getElementById('sidebar-backdrop')?.classList.remove('visible');
}
function initSidebarRail() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const rail = el('div', { id: 'sidebar-rail', class: 'sidebar-rail' });
const body = el('div', { id: 'sidebar-body', class: 'sidebar-body' });
sidebar.append(rail, body);
const railBtns = [
{ label: '\u2B21', title: 'Atlas', id: 'rail-atlas', panel: 'atlas' },
{ label: '\u25F7', title: 'Recent', id: 'rail-recent', panel: 'recent' },
{ label: '\u2630', title: 'Browse', id: 'rail-browse', panel: 'browse' },
{ 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;
rail.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) {
closeSidebar();
} 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);
}
});
});
let backdrop = document.getElementById('sidebar-backdrop');
if (!backdrop) {
backdrop = el('div', { id: 'sidebar-backdrop' });
document.body.appendChild(backdrop);
}
backdrop.addEventListener('click', closeSidebar);
initSidebarToggle();
}
function showSidebarPanel(panel) {
const body = document.getElementById('sidebar-body');
if (!body) return;
body.innerHTML = '';
const panelEl = el('div', { class: 'sidebar-panel', 'data-panel': panel });
panelEl.innerHTML = `
<div class="sidebar-header">
<span class="sidebar-header-label">${panel}</span>
<button class="sidebar-close-btn" aria-label="Close sidebar">\u00D7</button>
</div>
<div class="sidebar-panel-body" id="sidebar-panel-body"></div>
`;
body.appendChild(panelEl);
panelEl.querySelector('.sidebar-close-btn').addEventListener('click', closeSidebar);
const container = panelEl.querySelector('#sidebar-panel-body');
if (panel === 'atlas') buildAtlasPanel(container);
else if (panel === 'recent') buildRecentPanel(container);
else if (panel === 'browse') buildBrowsePanel(container);
}
async function buildAtlasPanel(container) {
const data = await fetchSidebarData();
const mocs = data.mocs || [];
if (!mocs.length) {
container.innerHTML = '<p class="sidebar-empty">No MOC hubs found.</p>';
return;
}
const grid = el('div', { class: 'moc-grid' });
mocs.forEach(moc => {
const card = el('button', {
class: 'moc-card' + (moc.pinned ? ' moc-card-pinned' : ''),
type: 'button',
});
card.innerHTML = `
<span class="moc-card-title">${escHtml(moc.title)}</span>
${moc.pinned ? '<span class="moc-card-badge">start</span>' : ''}
`;
card.addEventListener('click', () => {
closeSidebar();
openNote(normalisePathname(moc.url), moc.title);
});
grid.appendChild(card);
});
container.appendChild(grid);
}
function getRecentNotes() {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch (_) {
return [];
}
}
function trackRecentNote(pathname, title) {
if (!pathname) return;
const entry = { pathname, title, visitedAt: Date.now() };
let recent = getRecentNotes().filter(r => r.pathname !== pathname);
recent.unshift(entry);
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, RECENT_MAX)));
const panel = document.querySelector('.sidebar-panel[data-panel="recent"] #sidebar-panel-body');
if (panel) buildRecentPanel(panel);
}
function formatRelativeTime(ts) {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return new Date(ts).toLocaleDateString();
}
function buildRecentPanel(container) {
const recent = getRecentNotes();
container.innerHTML = '';
if (!recent.length) {
container.innerHTML = '<p class="sidebar-empty">No notes visited yet.</p>';
return;
}
const list = el('div', { class: 'recent-list' });
recent.forEach(item => {
const row = el('button', { class: 'recent-item', type: 'button' });
row.innerHTML = `
<span class="recent-item-title">${escHtml(item.title)}</span>
<span class="recent-item-time">${formatRelativeTime(item.visitedAt)}</span>
`;
row.addEventListener('click', () => {
closeSidebar();
openNote(item.pathname, item.title);
});
list.appendChild(row);
});
container.appendChild(list);
}
function buildBrowsePanel(container) {
container.innerHTML = `
<div class="tree-filter-wrap">
<input type="search" class="tree-filter" placeholder="Filter notes\u2026" aria-label="Filter notes" />
</div>
<div id="file-tree"></div>
`;
const filterInput = container.querySelector('.tree-filter');
const treeRoot = container.querySelector('#file-tree');
filterInput.addEventListener('input', () => filterTree(treeRoot, filterInput.value.trim()));
buildFileTree(treeRoot);
}
async function buildFileTree(container) {
const currentPath = location.pathname;
const data = await fetchSidebarData();
let treeData = Array.isArray(data.tree) ? data.tree : null;
if (treeData) {
renderTree(treeData, container, currentPath);
return;
}
const groups = await buildGroupsFromSearchIndex();
if (!groups?.length) {
container.innerHTML = '<p class="sidebar-empty">No notes found.</p>';
return;
}
const fallbackTree = groups.map(g => ({ label: g.label, children: [], files: g.files }));
renderTree(fallbackTree, container, currentPath);
}
function getFolderState() {
try {
return JSON.parse(localStorage.getItem(FOLDER_STATE_KEY) || '{}');
} catch (_) {
return {};
}
}
function setFolderState(key, isOpen) {
const state = getFolderState();
state[key] = isOpen;
localStorage.setItem(FOLDER_STATE_KEY, JSON.stringify(state));
}
function countFilesInNode(node) {
let count = (node.files || []).length;
for (const child of node.children || []) {
count += countFilesInNode(child);
}
return count;
}
function nodeContainsPath(node, pathname, prefix = '') {
const folderKey = prefix ? `${prefix}/${node.label}` : node.label;
for (const f of node.files || []) {
if (normalisePathname(f.url) === pathname) return true;
}
for (const child of node.children || []) {
if (nodeContainsPath(child, pathname, folderKey)) return true;
}
return false;
}
function renderTree(nodes, container, currentPath, prefix = '') {
nodes.forEach(node => {
const folderKey = prefix ? `${prefix}/${node.label}` : node.label;
const fileCount = countFilesInNode(node);
const containsActive = nodeContainsPath(node, currentPath, prefix);
const folderState = getFolderState();
const defaultOpen = containsActive || folderState[folderKey] === true;
const isOpen = defaultOpen;
const folderEl = el('div', {
class: 'tree-folder',
'data-folder': node.label,
'data-folder-key': folderKey,
});
const icon = el('span', { class: 'tree-icon' });
icon.textContent = isOpen ? '\u25BE ' : '\u25B8 ';
const labelSpan = el('span', { class: 'tree-folder-label' });
labelSpan.textContent = node.label;
const badge = el('span', { class: 'tree-folder-count' });
badge.textContent = String(fileCount);
folderEl.append(icon, labelSpan, badge);
const childrenWrap = el('div', { class: 'tree-children' });
if (!isOpen) childrenWrap.style.display = 'none';
const searchTerms = [node.label.toLowerCase()];
(node.files || []).forEach(f => {
const pathname = normalisePathname(f.url);
searchTerms.push(f.title.toLowerCase());
childrenWrap.appendChild(makeFileItem({ title: f.title, pathname }, currentPath));
});
if (node.children?.length) {
renderTree(node.children, childrenWrap, currentPath, folderKey);
}
folderEl.addEventListener('click', () => {
const nowOpen = childrenWrap.style.display === 'none';
childrenWrap.style.display = nowOpen ? '' : 'none';
icon.textContent = nowOpen ? '\u25BE ' : '\u25B8 ';
setFolderState(folderKey, nowOpen);
});
folderEl.dataset.searchText = searchTerms.join(' ');
childrenWrap.dataset.searchText = searchTerms.join(' ');
container.appendChild(folderEl);
container.appendChild(childrenWrap);
});
}
function filterTree(container, query) {
const q = query.toLowerCase();
container.querySelectorAll('.tree-folder, .tree-children, .tree-file').forEach(el => {
if (!q) {
el.classList.remove('tree-filtered-out');
return;
}
const text = (el.dataset.searchText || el.textContent || '').toLowerCase();
el.classList.toggle('tree-filtered-out', !text.includes(q));
});
if (q) {
container.querySelectorAll('.tree-children').forEach(wrap => {
wrap.style.display = '';
const folder = wrap.previousElementSibling;
if (folder?.classList.contains('tree-folder')) {
const icon = folder.querySelector('.tree-icon');
if (icon) icon.textContent = '\u25BE ';
}
});
}
}
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,
'data-search-text': title.toLowerCase(),
});
const iconSpan = el('span', { class: 'tree-icon' });
iconSpan.textContent = '\u00B7 ';
const titleSpan = el('span', { class: 'tree-file-title' });
titleSpan.textContent = title;
fileEl.appendChild(iconSpan);
fileEl.appendChild(titleSpan);
fileEl.title = title;
fileEl.addEventListener('click', () => {
closeSidebar();
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);
document.getElementById('tb-close-all')?.addEventListener('click', closeAllNotes);
// 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 = '<div class="note-loading">Loading\u2026</div>';
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 = '<p>Note loaded but content element not found.</p>';
return d;
})();
// Decode mermaid entities
contentEl.querySelectorAll('.mermaid').forEach(m => {
m.innerHTML = m.innerHTML
.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
});
fixRelativeSrcs(contentEl, pathname);
} catch (err) {
console.error('Note fetch error:', err);
contentEl = document.createElement('div');
contentEl.id = 'content';
contentEl.innerHTML =
`<p style="color:var(--fg-faint);padding:2rem 0">` +
`Could not load <code>${escHtml(pathname)}</code>: ${escHtml(err.message)}</p>`;
}
// 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;
const current = noteHistory[historyIndex];
if (current) {
trackRecentNote(current.pathname, current.label);
}
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 closeAllNotes() {
if (noteHistory.length <= 1) return;
const root = noteHistory[0];
noteHistory.slice(1).forEach(n => {
if (!n.isRoot) n.paneEl.remove();
});
noteHistory.splice(1);
historyIndex = 0;
activateNote(root.id, true);
history.replaceState(makeHistoryState(root), '', historyUrlFor(root));
}
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 = '<p class="bp-empty" style="opacity:.5;font-style:italic">Loading\u2026</p>';
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 = '<p class="bp-empty">No backlinks found for this note.</p>';
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 = '<p class="bp-empty">Could not load backlinks.</p>';
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 = `
<div class="preview-inner">
<div class="preview-title" id="preview-title">Note title</div>
<div class="preview-body" id="preview-body">Loading\u2026</div>
</div>
<div class="preview-footer">click to open</div>
`;
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 = 'atlas';
showSidebarPanel('atlas');
sidebar.querySelector('#rail-atlas')?.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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() {}
async function initHomeDashboard() {
const pathname = normalisePathname(location.pathname);
if (pathname !== '/' && !pathname.endsWith('/index.html')) return;
const content = document.querySelector('#content');
if (!content || content.querySelector('.home-dashboard')) return;
const data = await fetchSidebarData();
const mocs = data.mocs || [];
if (!mocs.length) return;
const dash = el('div', { class: 'home-dashboard' });
dash.innerHTML = `
<header class="home-hero">
<h2 class="home-hero-title">Zettelgarten</h2>
<p class="home-hero-sub">A private library of notes, maps, and wandering thoughts.</p>
</header>
<div class="home-quick-links">
<button type="button" class="home-quick-link" data-action="search">Search <kbd>/</kbd></button>
<a class="home-quick-link" href="/all-files.html">All files</a>
</div>
<h3 class="home-section-label">Start here</h3>
<div class="home-moc-grid"></div>
`;
const grid = dash.querySelector('.home-moc-grid');
mocs.forEach(moc => {
const card = el('a', {
class: 'moc-card home-moc-card' + (moc.pinned ? ' moc-card-pinned' : ''),
href: moc.url,
});
card.innerHTML = `
<span class="moc-card-title">${escHtml(moc.title)}</span>
${moc.pinned ? '<span class="moc-card-badge">start</span>' : ''}
`;
grid.appendChild(card);
});
dash.querySelector('[data-action="search"]')?.addEventListener('click', () => {
window.openSearchModal?.();
});
content.insertBefore(dash, content.firstChild);
}
// 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);