« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/static/client2.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/static/client2.js')
-rw-r--r--src/static/client2.js1049
1 files changed, 784 insertions, 265 deletions
diff --git a/src/static/client2.js b/src/static/client2.js
index 0cdb8b0e..758d91a6 100644
--- a/src/static/client2.js
+++ b/src/static/client2.js
@@ -6,13 +6,28 @@
 // ephemeral nature.
 
 import {getColors} from '../util/colors.js';
-import {getArtistNumContributions} from '../util/wiki-data.js';
+import {empty, stitchArrays} from '../util/sugar.js';
+
+import {
+  filterMultipleArrays,
+  getArtistNumContributions,
+} from '../util/wiki-data.js';
 
 let albumData, artistData;
 let officialAlbumData, fandomAlbumData, beyondAlbumData;
 
 let ready = false;
 
+const clientInfo = window.hsmusicClientInfo = Object.create(null);
+
+const clientSteps = {
+  getPageReferences: [],
+  addInternalListeners: [],
+  mutatePageContent: [],
+  initializeState: [],
+  addPageListeners: [],
+};
+
 // Localiz8tion nonsense ----------------------------------
 
 const language = document.documentElement.getAttribute('lang');
@@ -86,113 +101,148 @@ function fetchData(type, directory) {
 
 // JS-based links -----------------------------------------
 
-for (const a of document.body.querySelectorAll('[data-random]')) {
-  a.addEventListener('click', (evt) => {
-    if (!ready) {
-      evt.preventDefault();
-      return;
-    }
+const scriptedLinkInfo = clientInfo.scriptedLinkInfo = {
+  randomLinks: null,
+  revealLinks: null,
 
-    const tracks = albumData =>
-      albumData
-        .map(album => album.tracks)
-        .reduce((acc, tracks) => acc.concat(tracks), []);
+  nextLink: null,
+  previousLink: null,
+  randomLink: null,
+};
 
-    setTimeout(() => {
-      a.href = rebase('js-disabled');
-    });
+function getScriptedLinkReferences() {
+  scriptedLinkInfo.randomLinks =
+    document.querySelectorAll('[data-random]');
 
-    switch (a.dataset.random) {
-      case 'album':
-        a.href = openAlbum(pick(albumData).directory);
-        break;
-
-      case 'album-in-official':
-        a.href = openAlbum(pick(officialAlbumData).directory);
-        break;
-
-      case 'album-in-fandom':
-        a.href = openAlbum(pick(fandomAlbumData).directory);
-        break;
-
-      case 'album-in-beyond':
-        a.href = openAlbum(pick(beyondAlbumData).directory);
-        break;
-
-      case 'track':
-        a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
-        break;
-
-      case 'track-in-album':
-        a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
-        break;
-
-      case 'track-in-official':
-        a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData))));
-        break;
-
-      case 'track-in-fandom':
-        a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData))));
-        break;
-
-      case 'track-in-beyond':
-        a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData))));
-        break;
-
-      case 'artist':
-        a.href = openArtist(pick(artistData).directory);
-        break;
-
-      case 'artist-more-than-one-contrib':
-        a.href =
-          openArtist(
-            pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1))
-              .directory);
-        break;
-    }
-  });
+  scriptedLinkInfo.revealLinks =
+    document.getElementsByClassName('reveal');
+
+  scriptedLinkInfo.nextNavLink =
+    document.getElementById('next-button');
+
+  scriptedLinkInfo.previousNavLink =
+    document.getElementById('previous-button');
+
+  scriptedLinkInfo.randomNavLink =
+    document.getElementById('random-button');
 }
 
-const next = document.getElementById('next-button');
-const previous = document.getElementById('previous-button');
-const random = document.getElementById('random-button');
+function addRandomLinkListeners() {
+  for (const a of scriptedLinkInfo.randomLinks ?? []) {
+    a.addEventListener('click', evt => {
+      if (!ready) {
+        evt.preventDefault();
+        return;
+      }
+
+      const tracks = albumData =>
+        albumData
+          .map(album => album.tracks)
+          .reduce((acc, tracks) => acc.concat(tracks), []);
 
-const prependTitle = (el, prepend) => {
-  const existing = el.getAttribute('title');
-  if (existing) {
-    el.setAttribute('title', prepend + ' ' + existing);
-  } else {
-    el.setAttribute('title', prepend);
-  }
-};
+      setTimeout(() => {
+        a.href = rebase('js-disabled');
+      });
 
-if (next) prependTitle(next, '(Shift+N)');
-if (previous) prependTitle(previous, '(Shift+P)');
-if (random) prependTitle(random, '(Shift+R)');
-
-document.addEventListener('keypress', (event) => {
-  if (event.shiftKey) {
-    if (event.charCode === 'N'.charCodeAt(0)) {
-      if (next) next.click();
-    } else if (event.charCode === 'P'.charCodeAt(0)) {
-      if (previous) previous.click();
-    } else if (event.charCode === 'R'.charCodeAt(0)) {
-      if (random && ready) random.click();
-    }
+      switch (a.dataset.random) {
+        case 'album':
+          a.href = openAlbum(pick(albumData).directory);
+          break;
+
+        case 'album-in-official':
+          a.href = openAlbum(pick(officialAlbumData).directory);
+          break;
+
+        case 'album-in-fandom':
+          a.href = openAlbum(pick(fandomAlbumData).directory);
+          break;
+
+        case 'album-in-beyond':
+          a.href = openAlbum(pick(beyondAlbumData).directory);
+          break;
+
+        case 'track':
+          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
+          break;
+
+        case 'track-in-album':
+          a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
+          break;
+
+        case 'track-in-official':
+          a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData))));
+          break;
+
+        case 'track-in-fandom':
+          a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData))));
+          break;
+
+        case 'track-in-beyond':
+          a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData))));
+          break;
+
+        case 'artist':
+          a.href = openArtist(pick(artistData).directory);
+          break;
+
+        case 'artist-more-than-one-contrib':
+          a.href =
+            openArtist(
+              pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1))
+                .directory);
+          break;
+      }
+    });
   }
-});
+}
 
-for (const reveal of document.querySelectorAll('.reveal')) {
-  reveal.addEventListener('click', (event) => {
-    if (!reveal.classList.contains('revealed')) {
-      reveal.classList.add('revealed');
-      event.preventDefault();
-      event.stopPropagation();
-      reveal.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+function mutateNavigationLinkContent() {
+  const prependTitle = (el, prepend) =>
+    el?.setAttribute('title',
+      (el.hasAttribute('title')
+        ? prepend + ' ' + el.getAttribute('title')
+        : prepend));
+
+  prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)');
+  prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)');
+  prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)');
+}
+
+function addNavigationKeyPressListeners() {
+  document.addEventListener('keypress', (event) => {
+    if (event.shiftKey) {
+      if (event.charCode === 'N'.charCodeAt(0)) {
+        scriptedLinkInfo.nextNavLink?.click();
+      } else if (event.charCode === 'P'.charCodeAt(0)) {
+        scriptedLinkInfo.previousNavLink?.click();
+      } else if (event.charCode === 'R'.charCodeAt(0)) {
+        if (ready) {
+          scriptedLinkInfo.randomNavLink?.click();
+        }
+      }
     }
   });
 }
 
+function addRevealLinkClickListeners() {
+  for (const reveal of scriptedLinkInfo.revealLinks ?? []) {
+    reveal.addEventListener('click', (event) => {
+      if (!reveal.classList.contains('revealed')) {
+        reveal.classList.add('revealed');
+        event.preventDefault();
+        event.stopPropagation();
+        reveal.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+      }
+    });
+  }
+}
+
+clientSteps.getPageReferences.push(getScriptedLinkReferences);
+clientSteps.addPageListeners.push(addRandomLinkListeners);
+clientSteps.addPageListeners.push(addNavigationKeyPressListeners);
+clientSteps.addPageListeners.push(addRevealLinkClickListeners);
+clientSteps.mutatePageContent.push(mutateNavigationLinkContent);
+
 const elements1 = document.getElementsByClassName('js-hide-once-data');
 const elements2 = document.getElementsByClassName('js-show-once-data');
 
@@ -454,199 +504,393 @@ if (localStorage.tryInfoCards) {
 
 // Custom hash links --------------------------------------
 
-function addHashLinkHandlers() {
+const hashLinkInfo = clientInfo.hashLinkInfo = {
+  links: null,
+  hrefs: null,
+  targets: null,
+
+  state: {
+    highlightedTarget: null,
+    scrollingAfterClick: false,
+    concludeScrollingStateInterval: null,
+  },
+
+  event: {
+    whenHashLinkClicked: [],
+  },
+};
+
+function getHashLinkReferences() {
+  const info = hashLinkInfo;
+
+  info.links =
+    Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])'));
+
+  info.hrefs =
+    info.links
+      .map(link => link.getAttribute('href'));
+
+  info.targets =
+    info.hrefs
+      .map(href => document.getElementById(href.slice(1)));
+
+  filterMultipleArrays(
+    info.links,
+    info.hrefs,
+    info.targets,
+    (_link, _href, target) => target);
+}
+
+function processScrollingAfterHashLinkClicked() {
+  const {state} = hashLinkInfo;
+
+  if (state.concludeScrollingStateInterval) return;
+
+  let lastScroll = window.scrollY;
+  state.scrollingAfterClick = true;
+  state.concludeScrollingStateInterval = setInterval(() => {
+    if (Math.abs(window.scrollY - lastScroll) < 10) {
+      clearInterval(state.concludeScrollingStateInterval);
+      state.scrollingAfterClick = false;
+      state.concludeScrollingStateInterval = null;
+    } else {
+      lastScroll = window.scrollY;
+    }
+  }, 200);
+}
+
+function addHashLinkListeners() {
   // Instead of defining a scroll offset (to account for the sticky heading)
   // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
   // This lets the scroll offset be consolidated where it makes sense, and
   // sets an appropriate offset when (re)loading a page with hash for free!
 
-  let wasHighlighted;
-
-  for (const a of document.links) {
-    const href = a.getAttribute('href');
-    if (!href || !href.startsWith('#')) {
-      continue;
-    }
+  const info = hashLinkInfo;
+  const {state, event} = info;
 
-    a.addEventListener('click', handleHashLinkClicked);
-  }
+  for (const {hashLink, href, target} of stitchArrays({
+    hashLink: info.links,
+    href: info.hrefs,
+    target: info.targets,
+  })) {
+    hashLink.addEventListener('click', evt => {
+      if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
+        return;
+      }
 
-  function handleHashLinkClicked(evt) {
-    if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
-      return;
-    }
+      // Hide skipper box right away, so the layout is updated on time for the
+      // math operations coming up next.
+      const skipper = document.getElementById('skippers');
+      skipper.style.display = 'none';
+      setTimeout(() => skipper.style.display = '');
 
-    const href = evt.target.getAttribute('href');
-    const id = href.slice(1);
-    const linked = document.getElementById(id);
+      const box = target.getBoundingClientRect();
+      const style = window.getComputedStyle(target);
 
-    if (!linked) {
-      return;
-    }
+      const scrollY =
+          window.scrollY
+        + box.top
+        - style['scroll-margin-top'].replace('px', '');
 
-    // Hide skipper box right away, so the layout is updated on time for the
-    // math operations coming up next.
-    const skipper = document.getElementById('skippers');
-    skipper.style.display = 'none';
-    setTimeout(() => skipper.style.display = '');
+      evt.preventDefault();
+      history.pushState({}, '', href);
+      window.scrollTo({top: scrollY, behavior: 'smooth'});
+      target.focus({preventScroll: true});
 
-    const box = linked.getBoundingClientRect();
-    const style = window.getComputedStyle(linked);
+      const maxScroll =
+          document.body.scrollHeight
+        - window.innerHeight;
 
-    const scrollY =
-        window.scrollY
-      + box.top
-      - style['scroll-margin-top'].replace('px', '');
+      if (scrollY > maxScroll && target.classList.contains('content-heading')) {
+        if (state.highlightedTarget) {
+          state.highlightedTarget.classList.remove('highlight-hash-link');
+        }
 
-    evt.preventDefault();
-    history.pushState({}, '', href);
-    window.scrollTo({top: scrollY, behavior: 'smooth'});
-    linked.focus({preventScroll: true});
+        target.classList.add('highlight-hash-link');
+        state.highlightedTarget = target;
+      }
 
-    const maxScroll =
-        document.body.scrollHeight
-      - window.innerHeight;
+      processScrollingAfterHashLinkClicked();
 
-    if (scrollY > maxScroll && linked.classList.contains('content-heading')) {
-      if (wasHighlighted) {
-        wasHighlighted.classList.remove('highlight-hash-link');
+      for (const handler of event.whenHashLinkClicked) {
+        handler({
+          link: hashLink,
+        });
       }
+    });
+  }
 
-      wasHighlighted = linked;
-      linked.classList.add('highlight-hash-link');
-      linked.addEventListener('animationend', function handle(evt) {
-        if (evt.animationName === 'highlight-hash-link') {
-          linked.removeEventListener('animationend', handle);
-          linked.classList.remove('highlight-hash-link');
-          wasHighlighted = null;
-        }
-      });
-    }
+  for (const target of info.targets) {
+    target.addEventListener('animationend', evt => {
+      if (evt.animationName !== 'highlight-hash-link') return;
+      target.classList.remove('highlight-hash-link');
+      if (target !== state.highlightedTarget) return;
+      state.highlightedTarget = null;
+    });
   }
 }
 
-addHashLinkHandlers();
+clientSteps.getPageReferences.push(getHashLinkReferences);
+clientSteps.addPageListeners.push(addHashLinkListeners);
 
 // Sticky content heading ---------------------------------
 
-const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-heading-container'))
-  .map(stickyContainer => {
-    const {parentElement: contentContainer} = stickyContainer;
-    const stickySubheadingRow = stickyContainer.querySelector('.content-sticky-subheading-row');
-    const stickySubheading = stickySubheadingRow.querySelector('h2');
-    const stickyCoverContainer = stickyContainer.querySelector('.content-sticky-heading-cover-container');
-    const stickyCover = stickyCoverContainer?.querySelector('.content-sticky-heading-cover');
-    const contentHeadings = Array.from(contentContainer.querySelectorAll('.content-heading'));
-    const contentCover = contentContainer.querySelector('#cover-art-container');
-
-    return {
-      contentContainer,
-      contentCover,
-      contentHeadings,
-      stickyContainer,
-      stickyCover,
-      stickyCoverContainer,
-      stickySubheading,
-      stickySubheadingRow,
-      state: {
-        displayedHeading: null,
-      },
-    };
-  });
+const stickyHeadingInfo = clientInfo.stickyHeadingInfo = {
+  stickyContainers: null,
 
-const topOfViewInside = (el, scroll = window.scrollY) => (
-  scroll > el.offsetTop &&
-  scroll < el.offsetTop + el.offsetHeight
-);
-
-function prepareStickyHeadings() {
-  for (const {
-    contentCover,
-    stickyCover,
-  } of stickyHeadingInfo) {
-    const coverRevealImage = contentCover?.querySelector('.reveal');
-    if (coverRevealImage) {
-      stickyCover.classList.add('content-sticky-heading-cover-needs-reveal');
-      coverRevealImage.addEventListener('hsmusic-reveal', () => {
-        stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
-      });
+  stickySubheadingRows: null,
+  stickySubheadings: null,
+
+  stickyCoverContainers: null,
+  stickyCoverTextAreas: null,
+  stickyCovers: null,
+
+  contentContainers: null,
+  contentHeadings: null,
+  contentCovers: null,
+  contentCoversReveal: null,
+
+  state: {
+    displayedHeading: null,
+  },
+
+  event: {
+    whenDisplayedHeadingChanges: [],
+  },
+};
+
+function getStickyHeadingReferences() {
+  const info = stickyHeadingInfo;
+
+  info.stickyContainers =
+    Array.from(document.getElementsByClassName('content-sticky-heading-container'));
+
+  info.stickyCoverContainers =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-cover-container'));
+
+  info.stickyCovers =
+    info.stickyCoverContainers
+      .map(el => el?.querySelector('.content-sticky-heading-cover'));
+
+  info.stickyCoverTextAreas =
+    info.stickyCovers
+      .map(el => el?.querySelector('.image-text-area'));
+
+  info.stickySubheadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-subheading-row'));
+
+  info.stickySubheadings =
+    info.stickySubheadingRows
+      .map(el => el.querySelector('h2'));
+
+  info.contentContainers =
+    info.stickyContainers
+      .map(el => el.parentElement);
+
+  info.contentCovers =
+    info.contentContainers
+      .map(el => el.querySelector('#cover-art-container'));
+
+  info.contentCoversReveal =
+    info.contentCovers
+      .map(el => el ? !!el.querySelector('.reveal') : null);
+
+  info.contentHeadings =
+    info.contentContainers
+      .map(el => Array.from(el.querySelectorAll('.content-heading')));
+}
+
+function removeTextPlaceholderStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const hasTextArea =
+    info.stickyCoverTextAreas.map(el => !!el);
+
+  const coverContainersWithTextArea =
+    info.stickyCoverContainers
+      .filter((_el, index) => hasTextArea[index]);
+
+  for (const el of coverContainersWithTextArea) {
+    el.remove();
+  }
+
+  info.stickyCoverContainers =
+    info.stickyCoverContainers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCovers =
+    info.stickyCovers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCoverTextAreas =
+    info.stickyCoverTextAreas
+      .slice()
+      .fill(null);
+}
+
+function addRevealClassToStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const stickyCoversWhichReveal =
+    info.stickyCovers
+      .filter((_el, index) => info.contentCoversReveal[index]);
+
+  for (const el of stickyCoversWhichReveal) {
+    el.classList.add('content-sticky-heading-cover-needs-reveal');
+  }
+}
+
+function addRevealListenersForStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const stickyCovers = info.stickyCovers.slice();
+  const contentCovers = info.contentCovers.slice();
+
+  filterMultipleArrays(
+    stickyCovers,
+    contentCovers,
+    (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]);
+
+  for (const {stickyCover, contentCover} of stitchArrays({
+    stickyCover: stickyCovers,
+    contentCover: contentCovers,
+  })) {
+    // TODO: Janky - should use internal event instead of DOM event
+    contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => {
+      stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
+    });
+  }
+}
+
+function topOfViewInside(el, scroll = window.scrollY) {
+  return (
+    scroll > el.offsetTop &&
+    scroll < el.offsetTop + el.offsetHeight);
+}
+
+function updateStickyCoverVisibility(index) {
+  const info = stickyHeadingInfo;
+
+  const stickyCoverContainer = info.stickyCoverContainers[index];
+  const contentCover = info.contentCovers[index];
+
+  if (contentCover && stickyCoverContainer) {
+    if (contentCover.getBoundingClientRect().bottom < 0) {
+      stickyCoverContainer.classList.add('visible');
+    } else {
+      stickyCoverContainer.classList.remove('visible');
     }
   }
 }
 
-function updateStickyHeading() {
-  for (const {
-    contentContainer,
-    contentCover,
-    contentHeadings,
-    stickyContainer,
-    stickyCoverContainer,
-    stickySubheading,
-    stickySubheadingRow,
-    state,
-  } of stickyHeadingInfo) {
-    let closestHeading = null;
+function getContentHeadingClosestToStickySubheading(index) {
+  const info = stickyHeadingInfo;
 
-    if (contentCover && stickyCoverContainer) {
-      if (contentCover.getBoundingClientRect().bottom < 0) {
-        stickyCoverContainer.classList.add('visible');
-      } else {
-        stickyCoverContainer.classList.remove('visible');
-      }
+  const contentContainer = info.contentContainers[index];
+
+  if (!topOfViewInside(contentContainer)) {
+    return null;
+  }
+
+  const stickySubheading = info.stickySubheadings[index];
+
+  if (stickySubheading.childNodes.length === 0) {
+    // Supply a non-breaking space to ensure correct basic line height.
+    stickySubheading.appendChild(document.createTextNode('\xA0'));
+  }
+
+  const stickyContainer = info.stickyContainers[index];
+  const stickyRect = stickyContainer.getBoundingClientRect();
+
+  // TODO: Should this compute with the subheading row instead of h2?
+  const subheadingRect = stickySubheading.getBoundingClientRect();
+
+  const stickyBottom = stickyRect.bottom + subheadingRect.height;
+
+  // Iterate from bottom to top of the content area.
+  const contentHeadings = info.contentHeadings[index];
+  for (const heading of contentHeadings.slice().reverse()) {
+    const headingRect = heading.getBoundingClientRect();
+    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
+      return heading;
     }
+  }
 
-    if (topOfViewInside(contentContainer)) {
-      if (stickySubheading.childNodes.length === 0) {
-        // &nbsp; to ensure correct basic line height
-        stickySubheading.appendChild(document.createTextNode('\xA0'));
-      }
+  return null;
+}
 
-      const stickyRect = stickyContainer.getBoundingClientRect();
-      const subheadingRect = stickySubheading.getBoundingClientRect();
-      const stickyBottom = stickyRect.bottom + subheadingRect.height;
-
-      // This array is reversed so that we're starting from the bottom when
-      // iterating over it.
-      for (let i = contentHeadings.length - 1; i >= 0; i--) {
-        const heading = contentHeadings[i];
-        const headingRect = heading.getBoundingClientRect();
-        if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
-          closestHeading = heading;
-          break;
+function updateStickySubheadingContent(index) {
+  const info = stickyHeadingInfo;
+  const {event, state} = info;
+
+  const closestHeading = getContentHeadingClosestToStickySubheading(index);
+
+  if (state.displayedHeading === closestHeading) return;
+
+  const stickySubheadingRow = info.stickySubheadingRows[index];
+
+  if (closestHeading) {
+    const stickySubheading = info.stickySubheadings[index];
+
+    // Array.from needed to iterate over a live array with for..of
+    for (const child of Array.from(stickySubheading.childNodes)) {
+      child.remove();
+    }
+
+    for (const child of closestHeading.childNodes) {
+      if (child.tagName === 'A') {
+        for (const grandchild of child.childNodes) {
+          stickySubheading.appendChild(grandchild.cloneNode(true));
         }
+      } else {
+        stickySubheading.appendChild(child.cloneNode(true));
       }
     }
 
-    if (state.displayedHeading !== closestHeading) {
-      if (closestHeading) {
-        // Array.from needed to iterate over a live array with for..of
-        for (const child of Array.from(stickySubheading.childNodes)) {
-          child.remove();
-        }
+    stickySubheadingRow.classList.add('visible');
+  } else {
+    stickySubheadingRow.classList.remove('visible');
+  }
 
-        for (const child of closestHeading.childNodes) {
-          if (child.tagName === 'A') {
-            for (const grandchild of child.childNodes) {
-              stickySubheading.appendChild(grandchild.cloneNode(true));
-            }
-          } else {
-            stickySubheading.appendChild(child.cloneNode(true));
-          }
-        }
+  const oldDisplayedHeading = state.displayedHeading;
 
-        stickySubheadingRow.classList.add('visible');
-      } else {
-        stickySubheadingRow.classList.remove('visible');
-      }
+  state.displayedHeading = closestHeading;
 
-      state.displayedHeading = closestHeading;
-    }
+  for (const handler of event.whenDisplayedHeadingChanges) {
+    handler(index, {
+      oldHeading: oldDisplayedHeading,
+      newHeading: closestHeading,
+    });
   }
 }
 
-document.addEventListener('scroll', updateStickyHeading);
-prepareStickyHeadings();
-updateStickyHeading();
+function updateStickyHeadings(index) {
+  updateStickyCoverVisibility(index);
+  updateStickySubheadingContent(index);
+}
+
+function initializeStateForStickyHeadings() {
+  for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
+    updateStickyHeadings(i);
+  }
+}
+
+function addScrollListenerForStickyHeadings() {
+  document.addEventListener('scroll', () => {
+    for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
+      updateStickyHeadings(i);
+    }
+  });
+}
+
+clientSteps.getPageReferences.push(getStickyHeadingReferences);
+clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers);
+clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers);
+clientSteps.initializeState.push(initializeStateForStickyHeadings);
+clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers);
+clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings);
 
 // Image overlay ------------------------------------------
 
@@ -709,24 +953,48 @@ function handleImageLinkClicked(evt) {
   const mainImage = document.getElementById('image-overlay-image');
   const thumbImage = document.getElementById('image-overlay-image-thumb');
 
-  const mainThumbSize = getPreferredThumbSize();
-
-  const source = evt.target.closest('a').href;
+  const {href: originalSrc} = evt.target.closest('a');
+  const {dataset: {
+    originalSize: originalFileSize,
+    thumbs: availableThumbList,
+  }} = evt.target.closest('a').querySelector('img');
+
+  updateFileSizeInformation(originalFileSize);
+
+  let mainSrc = null;
+  let thumbSrc = null;
+
+  if (availableThumbList) {
+    const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList);
+    const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList);
+    mainSrc = originalSrc.replace(/\.(jpg|png)$/, `.${mainThumb}.jpg`);
+    thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${smallThumb}.jpg`);
+    // Show the thumbnail size on each <img> element's data attributes.
+    // Y'know, just for debugging convenience.
+    mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`;
+    thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`;
+  } else {
+    mainSrc = originalSrc;
+    thumbSrc = null;
+    mainImage.dataset.displayingThumb = '';
+    thumbImage.dataset.displayingThumb = '';
+  }
 
-  const mainSrc = source.replace(/\.(jpg|png)$/, `.${mainThumbSize}.jpg`);
-  const thumbSrc = source.replace(/\.(jpg|png)$/, '.small.jpg');
+  if (thumbSrc) {
+    thumbImage.src = thumbSrc;
+    thumbImage.style.display = null;
+  } else {
+    thumbImage.src = '';
+    thumbImage.style.display = 'none';
+  }
 
-  thumbImage.src = thumbSrc;
   for (const viewOriginal of allViewOriginal) {
-    viewOriginal.href = source;
+    viewOriginal.href = originalSrc;
   }
 
   mainImage.addEventListener('load', handleMainImageLoaded);
   mainImage.addEventListener('error', handleMainImageErrored);
 
-  const fileSize = evt.target.closest('a').querySelector('img').dataset.originalSize;
-  updateFileSizeInformation(fileSize);
-
   container.style.setProperty('--download-progress', '0%');
   loadImage(mainSrc, progress => {
     container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%');
@@ -750,7 +1018,21 @@ function handleImageLinkClicked(evt) {
   }
 }
 
-function getPreferredThumbSize() {
+function parseThumbList(availableThumbList) {
+  // Parse all the available thumbnail sizes! These are provided by the actual
+  // content generation on each image.
+  const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250'
+  const availableSizes =
+    (availableThumbList || defaultThumbList)
+      .split(' ')
+      .map(part => part.split(':'))
+      .map(([thumb, length]) => ({thumb, length: parseInt(length)}))
+      .sort((a, b) => a.length - b.length);
+
+  return availableSizes;
+}
+
+function getPreferredThumbSize(availableThumbList) {
   // Assuming a square, the image will be constrained to the lesser window
   // dimension. Coefficient here matches CSS dimensions for image overlay.
   const constrainedLength = Math.floor(Math.min(
@@ -761,17 +1043,30 @@ function getPreferredThumbSize() {
   // device configurations.
   const visualLength = window.devicePixelRatio * constrainedLength;
 
-  const largeLength = 800;
-  const semihugeLength = 1200;
+  const availableSizes = parseThumbList(availableThumbList);
+
+  // Starting from the smallest dimensions, find (and return) the first
+  // available length which hits a "good enough" threshold - it's got to be
+  // at least that percent of the way to the actual displayed dimensions.
   const goodEnoughThreshold = 0.90;
 
-  if (Math.floor(visualLength * goodEnoughThreshold) <= largeLength) {
-    return 'large';
-  } else if (Math.floor(visualLength * goodEnoughThreshold) <= semihugeLength) {
-    return 'semihuge';
-  } else {
-    return 'huge';
+  // (The last item is skipped since we'd be falling back to it anyway.)
+  for (const {thumb, length} of availableSizes.slice(0, -1)) {
+    if (Math.floor(visualLength * goodEnoughThreshold) <= length) {
+      return {thumb, length};
+    }
   }
+
+  // If none of the items in the list were big enough to hit the "good enough"
+  // threshold, just use the largest size available.
+  return availableSizes[availableSizes.length - 1];
+}
+
+function getSmallestThumbSize(availableThumbList) {
+  // Just snag the smallest size. This'll be used for displaying the "preview"
+  // as the bigger one is loading.
+  const availableSizes = parseThumbList(availableThumbList);
+  return availableSizes[0];
 }
 
 function updateFileSizeInformation(fileSize) {
@@ -913,3 +1208,227 @@ for (const info of groupContributionsTableInfo) {
     sortGroupContributionsTableBy(info, 'count');
   });
 }
+
+// Sticky commentary sidebar ------------------------------
+
+const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = {
+  sidebar: null,
+
+  sidebarTrackLinks: null,
+  sidebarTrackDirectories: null,
+
+  sidebarTrackSections: null,
+  sidebarTrackSectionStartIndices: null,
+
+  state: {
+    currentTrackSection: null,
+    currentTrackLink: null,
+    justChangedTrackSection: false,
+  },
+};
+
+function getAlbumCommentarySidebarReferences() {
+  const info = albumCommentarySidebarInfo;
+
+  info.sidebar =
+    document.getElementById('sidebar-left');
+
+  info.sidebarHeading =
+    info.sidebar.querySelector('h1');
+
+  info.sidebarTrackLinks =
+    Array.from(info.sidebar.querySelectorAll('li a'));
+
+  info.sidebarTrackDirectories =
+    info.sidebarTrackLinks
+      .map(el => el.getAttribute('href')?.slice(1) ?? null);
+
+  info.sidebarTrackSections =
+    Array.from(info.sidebar.getElementsByTagName('details'));
+
+  info.sidebarTrackSectionStartIndices =
+    info.sidebarTrackSections
+      .map(details => details.querySelector('ol, ul'))
+      .reduce(
+        (accumulator, _list, index, array) =>
+          (empty(accumulator)
+            ? [0]
+            : [
+              ...accumulator,
+              (accumulator[accumulator.length - 1] +
+                array[index - 1].querySelectorAll('li a').length),
+            ]),
+        []);
+}
+
+function scrollAlbumCommentarySidebar() {
+  const info = albumCommentarySidebarInfo;
+  const {state} = info;
+  const {currentTrackLink, currentTrackSection} = state;
+
+  if (!currentTrackLink) {
+    return;
+  }
+
+  const {sidebar, sidebarHeading} = info;
+
+  const scrollTop = sidebar.scrollTop;
+
+  const headingRect = sidebarHeading.getBoundingClientRect();
+  const sidebarRect = sidebar.getBoundingClientRect();
+
+  const stickyPadding = headingRect.height;
+  const sidebarViewportHeight = sidebarRect.height - stickyPadding;
+
+  const linkRect = currentTrackLink.getBoundingClientRect();
+  const sectionRect = currentTrackSection.getBoundingClientRect();
+
+  const sectionTopEdge =
+    sectionRect.top - (sidebarRect.top - scrollTop);
+
+  const sectionHeight =
+    sectionRect.height;
+
+  const sectionScrollTop =
+    sectionTopEdge - stickyPadding - 10;
+
+  const linkTopEdge =
+    linkRect.top - (sidebarRect.top - scrollTop);
+
+  const linkBottomEdge =
+    linkRect.bottom - (sidebarRect.top - scrollTop);
+
+  const linkScrollTop =
+    linkTopEdge - stickyPadding - 5;
+
+  const linkDistanceFromSection =
+    linkScrollTop - sectionTopEdge;
+
+  const linkVisibleFromTopOfSection =
+    linkBottomEdge - sectionTopEdge > sidebarViewportHeight;
+
+  const linkScrollBottom =
+    linkScrollTop - sidebarViewportHeight + linkRect.height + 20;
+
+  const maxScrollInViewport =
+    scrollTop + stickyPadding + sidebarViewportHeight;
+
+  const minScrollInViewport =
+    scrollTop + stickyPadding;
+
+  if (linkBottomEdge > maxScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (linkTopEdge < minScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (state.justChangedTrackSection) {
+    if (sectionHeight < sidebarViewportHeight) {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  }
+}
+
+function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) {
+  const info = albumCommentarySidebarInfo;
+  const {state} = info;
+
+  const trackIndex =
+    (trackDirectory
+      ? info.sidebarTrackDirectories
+          .indexOf(trackDirectory)
+      : -1);
+
+  const sectionIndex =
+    (trackIndex >= 0
+      ? info.sidebarTrackSectionStartIndices
+          .findIndex((start, index, array) =>
+            (index === array.length - 1
+              ? true
+              : trackIndex < array[index + 1]))
+      : -1);
+
+  const sidebarTrackLink =
+    (trackIndex >= 0
+      ? info.sidebarTrackLinks[trackIndex]
+      : null);
+
+  const sidebarTrackSection =
+    (sectionIndex >= 0
+      ? info.sidebarTrackSections[sectionIndex]
+      : null);
+
+  state.currentTrackLink?.classList?.remove('current');
+  state.currentTrackLink = sidebarTrackLink;
+  state.currentTrackLink?.classList?.add('current');
+
+  if (sidebarTrackSection !== state.currentTrackSection) {
+    if (sidebarTrackSection && !sidebarTrackSection.open) {
+      if (state.currentTrackSection) {
+        state.currentTrackSection.open = false;
+      }
+
+      sidebarTrackSection.open = true;
+    }
+
+    state.currentTrackSection?.classList?.remove('current');
+    state.currentTrackSection = sidebarTrackSection;
+    state.currentTrackSection?.classList?.add('current');
+    state.justChangedTrackSection = true;
+  } else {
+    state.justChangedTrackSection = false;
+  }
+}
+
+function addAlbumCommentaryInternalListeners() {
+  const info = albumCommentarySidebarInfo;
+
+  const mainContentIndex =
+    (stickyHeadingInfo.contentContainers ?? [])
+      .findIndex(({id}) => id === 'content');
+
+  if (mainContentIndex === -1) return;
+
+  stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => {
+    if (index !== mainContentIndex) return;
+    if (hashLinkInfo.state.scrollingAfterClick) return;
+
+    const trackDirectory =
+      (newHeading
+        ? newHeading.id
+        : null);
+
+    markDirectoryAsCurrentForAlbumCommentary(trackDirectory);
+    scrollAlbumCommentarySidebar();
+  });
+
+  hashLinkInfo.event.whenHashLinkClicked.push(({link}) => {
+    const hash = link.getAttribute('href').slice(1);
+    if (!info.sidebarTrackDirectories.includes(hash)) return;
+    markDirectoryAsCurrentForAlbumCommentary(hash);
+  });
+}
+
+if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') {
+  clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences);
+  clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners);
+}
+
+// Run setup steps ----------------------------------------
+
+for (const [key, steps] of Object.entries(clientSteps)) {
+  for (const step of steps) {
+    try {
+      step();
+    } catch (error) {
+      console.warn(`During ${key}, failed to run ${step.name}`);
+      console.debug(error);
+    }
+  }
+}