1302 lines
42 KiB
JavaScript
1302 lines
42 KiB
JavaScript
/* =========================================================
|
|
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 = `
|
|
<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" id="tb-back" title="Go back" aria-label="Go back">\u2190 back</button>
|
|
<button class="toolbar-btn" id="tb-forward" title="Go forward" aria-label="Go forward">forward \u2192</button>
|
|
<div class="toolbar-sep"></div>
|
|
<button class="toolbar-btn" id="tb-toc" title="Toggle table of contents">contents</button>
|
|
<div class="toolbar-spacer"></div>
|
|
<span class="note-graph-pos" id="note-graph-pos"></span>
|
|
<div class="toolbar-sep"></div>
|
|
<button class="toolbar-btn" id="tb-backlinks" title="Toggle backlinks panel">backlinks</button>
|
|
`;
|
|
}
|
|
|
|
/* =========================================================
|
|
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') {
|
|
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 = `
|
|
<div class="sidebar-header">
|
|
<span class="sidebar-header-label">all notes</span>
|
|
<button class="sidebar-close-btn" aria-label="Close sidebar">\u00D7</button>
|
|
</div>
|
|
<div id="file-tree"></div>
|
|
`;
|
|
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 = '<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(/>/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 =
|
|
`<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;
|
|
|
|
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 = '<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 = '';
|
|
|
|
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 = '';
|
|
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, '>')
|
|
.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);
|