clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / site / assets / diffs.ts
diffs.ts 7.4K
1// Diff files fold: click a file's header, or press x for the file in view.2// Pages with a [data-viewed] counter (reviews) also give each file a Viewed3// box, remembered in localStorage under the repo, the path and a hash of the4// file's diff. A force-push that leaves a file's diff alone keeps it checked;5// one that changes it clears it. Viewed files start folded, and so do files6// Jev marked mechanical ([data-mechanical]); v toggles the file in view. On7// review pages the sticky bar's commit arrows follow the commit in view.8(() => {9 // FNV-1a: short and stable, enough to tell two versions of a diff apart.10 const hash = (s: string): string => {11 let h = 0x811c9dc5;12 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);13 return (h >>> 0).toString(36);14 };1516 // The diff's own lines, without line numbers or hunk positions, which shift17 // when the base moves under an unchanged change.18 const text = (f: HTMLElement): string => {19 const rows = [...f.querySelectorAll('.code .l, .code .hunk')].map((l) =>20 l.classList.contains('hunk') ? '@@' : (l.querySelector('.mk')?.textContent ?? '') + (l.querySelector('.lc')?.textContent ?? ''));21 return rows.length ? rows.join('\n') : f.querySelector('.note')?.textContent ?? '';22 };2324 const files = (): HTMLElement[] => [...document.querySelectorAll<HTMLElement>('section.file')];25 const boxOf = (f: HTMLElement): HTMLInputElement | null => f.querySelector('.fh .viewed input');26 const nextFile = (f: HTMLElement): HTMLElement | undefined => files()[files().indexOf(f) + 1];27 // The bottom edge of the sticky review bar, or the top of the window.28 const line = (): number => Math.max(0, document.querySelector('.revnav')?.getBoundingClientRect().bottom ?? 0);29 // The file in view: the first one not yet scrolled past, or the one folding30 // just moved on to, while the page hasn't scrolled since (near the end it31 // can't scroll far enough to bring that file to the top).32 let moved: { f: HTMLElement; y: number } | null = null;33 const current = (): HTMLElement | undefined =>34 moved?.y === scrollY ? moved.f : files().find((f) => f.getBoundingClientRect().bottom > line() + 8);3536 function fold(f: HTMLElement, on: boolean): void {37 // Folding the file being read (its header stuck at the top) moves on to the next.38 const reading = on && f.getBoundingClientRect().top <= line() + 8;39 f.classList.toggle('folded', on);40 f.querySelector('.fold')?.setAttribute('aria-expanded', String(!on));41 const next = reading ? nextFile(f) : null;42 if (reading) (next ?? f).scrollIntoView();43 moved = next ? { f: next, y: scrollY } : null;44 }4546 function count(): void {47 const c = document.querySelector<HTMLElement>('[data-viewed]');48 if (!c) return;49 const boxes = files().map(boxOf).filter((b): b is HTMLInputElement => !!b);50 c.innerHTML = `${boxes.filter((b) => b.checked).length} of ${boxes.length} <span class="wide">file${boxes.length === 1 ? '' : 's'} </span>viewed`;51 c.hidden = !boxes.length;52 }5354 function view(f: HTMLElement, on: boolean): void {55 const box = boxOf(f);56 if (!box) return;57 box.checked = on;58 if (on) localStorage.setItem(box.dataset.key ?? '', '1');59 else localStorage.removeItem(box.dataset.key ?? '');60 fold(f, on);61 count();62 }6364 function setup(): void {65 const slug = document.querySelector<HTMLElement>('[data-viewed]')?.dataset.viewed;66 for (const f of files()) {67 const head = f.querySelector<HTMLElement>(':scope > .fh');68 if (!head || head.querySelector('.fold')) continue;69 const b = document.createElement('button');70 b.type = 'button';71 b.className = 'fold';72 b.title = 'fold or unfold (x)';73 b.setAttribute('aria-label', 'fold file');74 b.setAttribute('aria-expanded', 'true');75 head.prepend(b);76 head.classList.add('foldable');77 if (f.hasAttribute('data-mechanical')) {78 f.classList.add('folded');79 b.setAttribute('aria-expanded', 'false');80 }81 if (!slug) continue;82 const label = document.createElement('label');83 label.className = 'check viewed';84 label.title = 'viewed (v)';85 label.innerHTML = '<input type="checkbox">viewed';86 const box = label.querySelector('input')!;87 box.dataset.key = `forge.viewed ${slug} ${head.querySelector('.fname')?.textContent ?? ''} ${hash(text(f))}`;88 box.checked = !!localStorage.getItem(box.dataset.key);89 head.append(label);90 if (box.checked) {91 f.classList.add('folded');92 b.setAttribute('aria-expanded', 'false');93 }94 }95 count();96 reveal();97 track();98 }99100 // A link to a line inside a folded file unfolds it.101 function reveal(): void {102 const t = location.hash.length > 1 ? document.getElementById(decodeURIComponent(location.hash.slice(1))) : null;103 const f = t?.closest<HTMLElement>('section.file.folded');104 if (!f || !t) return;105 f.classList.remove('folded');106 f.querySelector('.fold')?.setAttribute('aria-expanded', 'true');107 t.scrollIntoView();108 }109110 // The bar's commit arrows point at the neighbours of the commit in view;111 // without JS they point at the first commit.112 function track(): void {113 const nav = document.querySelector<HTMLElement>('.revnav .commits');114 if (!nav) return;115 const secs = [...document.querySelectorAll<HTMLElement>('.stacked.jump')];116 let i = -1;117 secs.forEach((s, j) => {118 if (s.getBoundingClientRect().top <= line() + 8) i = j;119 });120 const at = nav.querySelector('.at');121 if (at) at.textContent = i < 0 ? `${secs.length} commits` : `commit ${i + 1}/${secs.length}`;122 const point = (a: HTMLAnchorElement | null, s: HTMLElement | undefined): void => {123 if (!a) return;124 if (s) {125 a.href = '#' + s.id;126 a.title = s.querySelector('h3 a')?.textContent ?? '';127 } else {128 a.removeAttribute('href');129 a.title = '';130 }131 };132 point(nav.querySelector('[data-step="-1"]'), secs[i - 1]);133 point(nav.querySelector('[data-step="1"]'), secs[i + 1]);134 }135136 document.addEventListener('click', (e) => {137 const t = e.target as Element;138 const head = t.closest<HTMLElement>('section.file > .fh.foldable');139 // Links and the Viewed box do their own thing; selecting text isn't a click.140 if (!head || t.closest('a, label, input, button:not(.fold)') || getSelection()?.type === 'Range') return;141 const f = head.parentElement!;142 fold(f, !f.classList.contains('folded'));143 });144145 document.addEventListener('change', (e) => {146 const box = (e.target as Element).closest<HTMLInputElement>('.fh .viewed input');147 const f = box?.closest<HTMLElement>('section.file');148 if (box && f) view(f, box.checked);149 });150151 document.addEventListener('keydown', (e) => {152 if ((e.target as Element | null)?.closest('input, textarea, select') || e.metaKey || e.ctrlKey || e.altKey) return;153 if ((e.key !== 'x' && e.key !== 'v') || document.querySelector('dialog[open]')) return;154 const f = current();155 if (!f) return;156 if (e.key === 'x') fold(f, !f.classList.contains('folded'));157 else if (boxOf(f)) view(f, !boxOf(f)!.checked);158 e.preventDefault();159 });160161 let queued = false;162 addEventListener('scroll', () => {163 if (queued) return;164 queued = true;165 requestAnimationFrame(() => {166 queued = false;167 track();168 });169 }, { passive: true });170 addEventListener('hashchange', reveal);171 // live.ts swaps in rebuilt content.172 document.addEventListener('forge:content', setup);173 setup();174})();