// Related-note stacking: load other notes in full below this one, kept in ?with=. Each body is the fragment the build publishes beside its note's page. import { sprigMarkup, accession } from './folio.mjs'; import { scoreCandidates } from './related.mjs'; import { searchNotes } from './search.mjs'; import { MAX_STACKED, parseWith, serializeWith, focusIntent, withTimeout, plainClick } from './stack.mjs'; import { parseNoteUrl, bodyUrl } from './routes.mjs'; const LOAD_TIMEOUT_MS = 10000; const panel = document.getElementById('stack-panel'); const stackEl = document.getElementById('stack'); const self = panel?.dataset.self; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; /** * Small element builder; children may be strings or nodes. * @template {keyof HTMLElementTagNameMap} K * @param {K} tag * @param {Record} [attrs] * @param {...(Node | string)} children * @returns {HTMLElementTagNameMap[K]} */ function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v === false || v == null) continue; if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v === true ? '' : v); } node.append(...children); return node; } /** A stage's sprig, the same markup the build prints. @param {string} stage */ function sprigEl(stage) { const t = document.createElement('template'); t.innerHTML = sprigMarkup(stage); return /** @type {Element} */ (t.content.firstElementChild); } /** A stage's sprig and name. @param {string} stage */ const stageBadge = (stage) => el('span', { class: 'toc-stage reg' }, sprigEl(stage), el('span', { class: 'label' }, stage)); /** @typedef {{ slug: string, title: string, url: string, stage: string, tended: string, age: string, tags: string[], links: string[], number?: number }} StackEntry one note in /notes/stack.json; age is its ink-fade bucket */ /** * Fill the Read alongside panel and stack the notes in ?with= below this one. * @param {HTMLElement} panel the Read alongside panel * @param {HTMLElement} stackEl where stacked notes go * @param {string} self the slug of the page's own note */ async function init(panel, stackEl, self) { /** @type {StackEntry[]} */ let entries; try { const res = await fetch('/notes/stack.json'); if (!res.ok) return; entries = await res.json(); } catch { return; // keep the static Related list } const bySlug = new Map(entries.map((e) => [e.slug, e])); if (!bySlug.has(self)) return; let set = parseWith(location.search, [...bySlug.keys()], self); /** @type {Map} */ const sections = new Map(); const intent = focusIntent(); const heading = /** @type {HTMLElement} */ (panel.querySelector('h2')); heading.textContent = 'Read alongside'; const suggestions = el('ul', { class: 'stack-suggestions' }); const input = el('input', { id: 'stack-any', type: 'text', role: 'combobox', placeholder: 'Title or tag', autocomplete: 'off', spellcheck: 'false', 'aria-autocomplete': 'list', 'aria-expanded': 'false', 'aria-controls': 'stack-any-list', }); const listbox = el('ul', { id: 'stack-any-list', class: 'stack-results', role: 'listbox', 'aria-label': 'Notes', hidden: true, onmousedown: (/** @type {MouseEvent} */ e) => e.preventDefault(), // keep focus in the box while picking or scrolling }); const none = el('p', { class: 'stack-none', role: 'status' }); const combo = el('div', { class: 'combo' }, input, listbox, none); const picker = el('div', { class: 'stack-picker' }, el('label', { for: 'stack-any' }, 'Any note…'), combo); const cap = el('p', { class: 'stack-cap', hidden: true }, 'That’s 8 notes; remove one to add another.'); panel.querySelector('ul')?.remove(); panel.append(suggestions, picker, cap); panel.hidden = false; function render() { const full = set.length >= MAX_STACKED; suggestions.replaceChildren( ...scoreCandidates(entries, [self, ...set], 5).map((e) => el('li', {}, el('button', { type: 'button', class: 'btn reg stack-add', disabled: full, onclick: () => add(e.slug, true) }, '+ ', e.title, ' ', stageBadge(e.stage))), ), ); suggestions.hidden = !suggestions.children.length; if (input.getAttribute('aria-expanded') === 'true') refresh(); cap.hidden = !full; // One page edge for the note itself, plus one per stacked note, up to three. const article = /** @type {HTMLElement | null} */ (document.querySelector('article.note')); if (article) article.dataset.edges = String(Math.min(1 + set.length, 3)); history.replaceState(history.state, '', location.pathname + serializeWith(location.search, set) + location.hash); } /** @param {string} slug */ function remove(slug) { set = set.filter((s) => s !== slug); sections.get(slug)?.remove(); sections.delete(slug); render(); heading.focus(); } /** @param {StackEntry} entry @param {HTMLElement} section */ function showError(entry, section) { const link = el('a', { href: entry.url }, 'Open it on its own →'); section.replaceChildren( el('p', { class: 'stack-error' }, 'Couldn’t load ', el('em', {}, entry.title), '. ', link, ' ', el('button', { type: 'button', class: 'btn stack-remove', 'aria-label': `Dismiss ${entry.title}`, onclick: () => remove(entry.slug) }, '×')), ); if (intent.take(entry.slug)) link.focus(); } /** @param {StackEntry} entry @param {HTMLElement} section */ async function load(entry, section) { /** @type {string | null} */ let body; try { const res = await withTimeout(fetch(bodyUrl(entry.slug)), LOAD_TIMEOUT_MS); if (!res.ok) throw new Error(String(res.status)); body = await res.text(); } catch { body = null; } if (!sections.has(entry.slug)) return; // removed while loading if (body == null) return showError(entry, section); const title = el('a', { class: 'stacked-title', href: entry.url }, entry.title); const content = el('div', { class: 'note-body' }); content.innerHTML = body; // same-origin fragment, its ids already scoped to the note section.classList.remove('is-loading'); section.dataset.age = entry.age; const no = accession(entry.number); section.replaceChildren( el('header', { class: 'stacked-header' }, no && el('span', { class: 'no' }, no), el('h2', {}, title), stageBadge(entry.stage), el('button', { type: 'button', class: 'btn stack-remove', 'aria-label': `Remove ${entry.title} from this page`, onclick: () => remove(entry.slug) }, '✕ Remove')), content, ); if (intent.take(entry.slug)) { title.focus({ preventScroll: true }); section.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }); flash(section); } } /** Scroll to a note already on the page: the page's own note, or a stacked one, which is focused and flashed. @param {string} slug */ function goTo(slug) { if (slug === self) { document.querySelector('article.note')?.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }); return; } const section = sections.get(slug); if (!section) return; section.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }); /** @type {HTMLElement | null} */ (section.querySelector('.stacked-title'))?.focus({ preventScroll: true }); flash(section); } // The Any note… box: a combobox whose listbox holds searchNotes results. /** @type {{ entry: StackEntry, tag?: string }[]} */ let results = []; let active = -1; /** @type {(slug: string) => boolean} */ const onPage = (slug) => slug === self || set.includes(slug); /** @type {(slug: string) => boolean} */ const pickable = (slug) => onPage(slug) || set.length < MAX_STACKED; function refresh() { results = searchNotes(entries, input.value); listbox.replaceChildren(...results.map(({ entry, tag }, i) => el('li', { id: `stack-opt-${entry.slug}`, role: 'option', class: 'stack-option', 'aria-selected': String(i === active), 'aria-disabled': !pickable(entry.slug) && 'true', onclick: () => pick(entry.slug), }, el('span', { class: 'opt-title' }, entry.title), ' ', stageBadge(entry.stage), tag != null ? el('span', { class: 'opt-note' }, `#${tag}`) : '', onPage(entry.slug) ? el('span', { class: 'opt-note' }, 'on this page') : ''))); const query = input.value.trim(); listbox.hidden = !results.length; none.textContent = results.length ? '' : `No notes match “${query}”.`; const current = results[active]; if (current) { input.setAttribute('aria-activedescendant', `stack-opt-${current.entry.slug}`); document.getElementById(`stack-opt-${current.entry.slug}`)?.scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant'); } function openList() { input.setAttribute('aria-expanded', 'true'); refresh(); } function closeList() { input.setAttribute('aria-expanded', 'false'); active = -1; input.removeAttribute('aria-activedescendant'); listbox.hidden = true; none.textContent = ''; } /** @param {string} slug */ function pick(slug) { if (!pickable(slug)) return; input.value = ''; closeList(); if (onPage(slug)) goTo(slug); else add(slug, true); } input.addEventListener('focus', openList); input.addEventListener('input', () => { active = input.value.trim() ? 0 : -1; // Enter takes the best match openList(); }); input.addEventListener('keydown', (e) => { const open = input.getAttribute('aria-expanded') === 'true'; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!open) return openList(); const step = e.key === 'ArrowDown' ? 1 : -1; active = Math.max(0, Math.min(results.length - 1, active + step)); refresh(); } else if (e.key === 'Enter' && open && results[active]) { e.preventDefault(); pick(results[active].entry.slug); } else if (e.key === 'Escape') { if (open) closeList(); else input.value = ''; } }); combo.addEventListener('focusout', (e) => { if (!combo.contains(/** @type {Node | null} */ (e.relatedTarget))) closeList(); }); /** Flash a stacked note's border, as the index does for a card reached from its contents. @param {HTMLElement} section */ function flash(section) { section.classList.remove('is-landed'); void section.offsetWidth; section.classList.add('is-landed'); } stackEl.addEventListener('animationend', (e) => { if (e.animationName === 'landed') /** @type {Element} */ (e.target).classList.remove('is-landed'); }); /** @param {string} slug @param {boolean} focus */ function place(slug, focus) { const entry = bySlug.get(slug); if (!entry) return; const section = el('section', { class: 'stacked-note sheet is-loading', 'data-slug': slug }, el('p', { class: 'stack-loading' }, 'Loading ', el('em', {}, entry.title), '…')); sections.set(slug, section); stackEl.append(section); if (focus) { // Hold focus on the placeholder: the button that was clicked may be re-rendered away. intent.claim(slug); section.setAttribute('tabindex', '-1'); section.focus({ preventScroll: true }); section.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }); } load(entry, section); } /** @param {string} slug @param {boolean} focus */ function add(slug, focus) { if (!bySlug.has(slug) || slug === self || set.includes(slug) || set.length >= MAX_STACKED) return; set = [...set, slug]; place(slug, focus); render(); } // Links between notes, in this note or a stacked one, add the linked note here instead of leaving. document.addEventListener('click', (e) => { const a = /** @type {HTMLAnchorElement | null} */ (/** @type {Element} */ (e.target).closest('.note-body a[href]')); if (!a || !plainClick(e)) return; const slug = parseNoteUrl(a.href, location.href); if (!slug || !bySlug.has(slug)) return; if (slug === self || sections.has(slug)) { e.preventDefault(); goTo(slug); } else if (set.length < MAX_STACKED) { e.preventDefault(); add(slug, true); } // With the stack full, the link simply opens the note. }); for (const slug of set) place(slug, false); render(); } if (panel && stackEl && self) init(panel, stackEl, self);