website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

website/src/scripts/post/folding.js (4220B)


// Org folding for a post. Each heading owns what follows it, up to the next
// heading at its level or above. Clicking its stars folds that away. With the
// stars focused, TAB does the same and S-TAB cycles the whole buffer
// (overview, contents, show all), as in the posts list on the home page.
// Folding toggles a class rather than moving anything, so videos keep playing.

import { $, $$ } from "../util.js";
import { defcommand, message } from "../emacs/index.js";

/** The post's headings that imenu and the *Ilist* window list. */
export const headings = () => $$(".post-body :is(h2, h3)[id]:not(.sr-only)");
const titles = new Map();
/** A heading's text without the stars and ellipsis added here. */
export const titleOf = (h) => titles.get(h) ?? h.textContent.trim();

const outline = []; // { h, level, folded, owners, stars, ell, hasContent }
const owned = []; // [element, owners] for everything under a heading
const nodeOf = new Map();
let contentsOnly = false;

export function initFolding() {
  const body = $(".post-body");
  if (!body) return;
  for (const h of headings()) titles.set(h, h.textContent.trim());
  const levelOf = (el) => (/^H[234]$/.test(el.tagName) && !el.classList.contains("sr-only") ? +el.tagName[1] - 1 : 0);
  const open = []; // the headings we're inside, outermost first
  for (const el of body.children) {
    if (el.matches(".footnotes")) break;
    const level = levelOf(el);
    if (!level) {
      owned.push([el, [...open]]);
      if (open.length) open.at(-1).hasContent = true;
      continue;
    }
    while (open.length && open.at(-1).level >= level) open.pop();
    const node = { h: el, level, folded: false, owners: [...open], hasContent: false };
    node.stars = Object.assign(document.createElement("button"), { type: "button", className: "org-stars", tabIndex: -1, textContent: "*".repeat(level) });
    node.stars.setAttribute("aria-label", "Fold or unfold");
    node.ell = Object.assign(document.createElement("span"), { className: "org-ellipsis", textContent: "...", hidden: true });
    node.ell.setAttribute("aria-hidden", "true");
    el.prepend(node.stars);
    el.append(node.ell);
    el.classList.add("has-stars");
    node.stars.addEventListener("click", () => {
      toggle(node);
      node.stars.focus({ preventScroll: true });
    });
    node.stars.addEventListener("keydown", (e) => {
      if (e.key !== "Tab") return;
      e.preventDefault();
      if (e.shiftKey) cycle();
      else toggle(node);
    });
    outline.push(node);
    nodeOf.set(el, node);
    open.push(node);
  }

  if (!outline.length) return;
  defcommand("org-shifttab", cycle, { doc: "Cycle the whole post: overview, contents, show all" });
  // S-TAB with nothing focused cycles too, like in the home page's Org buffer.
  addEventListener("keydown", (e) => {
    if (e.key === "Tab" && e.shiftKey && document.activeElement === document.body) {
      e.preventDefault();
      cycle();
    }
  });
}

function update() {
  const away = (owners) => owners.some((o) => o.folded);
  for (const n of outline) {
    n.h.classList.toggle("is-folded-away", away(n.owners));
    n.stars.setAttribute("aria-expanded", String(!n.folded));
    n.ell.hidden = !(n.folded || (contentsOnly && n.hasContent));
  }
  for (const [el, owners] of owned) {
    el.classList.toggle("is-folded-away", away(owners) || (contentsOnly && owners.length > 0));
  }
}

function toggle(node) {
  if (contentsOnly) {
    // Leaving the contents view: keep only the path to this heading open.
    contentsOnly = false;
    const path = new Set([node, ...node.owners]);
    for (const n of outline) n.folded = !path.has(n);
    node.folded = false;
  } else {
    node.folded = !node.folded;
  }
  update();
}

const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"];
let state = 2;
function cycle() {
  state = (state + 1) % 3;
  contentsOnly = state === 1;
  for (const n of outline) n.folded = state === 0 && n.level === 1;
  update();
  message(STATES[state]);
}

/** Unfolds whatever hides a heading, before jumping to it. */
export function reveal(h) {
  const node = nodeOf.get(h);
  if (!node) return;
  contentsOnly = false;
  for (const o of node.owners) o.folded = false;
  update();
}