PCGamingWiki — Table of Contents

Adds a table of contents (TOC) to PCGamingWiki articles, Wikipedia style (collapsible, anchored links, highlighting of the active section).

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         PCGamingWiki — Table of Contents
// @namespace    https://www.pcgamingwiki.com/
// @version      1.0
// @description  Adds a table of contents (TOC) to PCGamingWiki articles, Wikipedia style (collapsible, anchored links, highlighting of the active section).
// @author       ChatGPT
// @match        https://www.pcgamingwiki.com/*
// @grant        none
// @license MIT
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // ---------- Config ----------
  const MIN_HEADINGS = 2;
  const TOC_ID = 'pcgw-toc';
  const TOC_BTN_ID = 'pcgw-toc-btn';
  const STORAGE_KEY = 'pcgw_toc_open_v1'; // global state persistence
  const MOBILE_BREAKPOINT = 920;
  const TOC_FONT_PX = 14;
  const TOC_WIDTH_PX = 300;
  const TOP_GAP_PX = 56;
  const SELECTOR_CANDIDATES = [
    '#mw-content-text',
    '.mw-parser-output',
    '#content',
    '.page-content',
    '#bodyContent'
  ];
  // ------------------------------

  // Find the main content node
  function findContentNode() {
    for (const s of SELECTOR_CANDIDATES) {
      const el = document.querySelector(s);
      if (el) return el;
    }
    return document.body;
  }

  // Retrieve titles (without “edit”)
  function collectHeadings(root) {
    const all = Array.from(root.querySelectorAll('h2,h3,h4,h5,h6'));
    return all
      .filter(h => h.textContent && h.textContent.trim().length > 0)
      .map(h => {
        const level = parseInt(h.tagName.slice(1), 10);
        const clone = h.cloneNode(true);
        clone.querySelectorAll('.mw-editsection, .editsection, .mw-editsection-like').forEach(e => e.remove());
        const inner = clone.querySelector('.mw-headline');
        const text = (inner ? inner.textContent : clone.textContent).trim();
        return { el: h, level, text };
      })
      .filter(h => h.level >= 2 && h.level <= 6 && h.text.length > 0);
  }

  // Ensure unique IDs
  function ensureIds(headings) {
    const used = new Set();
    function slugify(t) {
      let s = (t && t.normalize) ? t.normalize('NFKD').replace(/[\u0300-\u036f]/g, '') : String(t || '');
      s = s.toLowerCase().replace(/<[^>]*>/g, '').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').replace(/-+/g, '-');
      if (!s) s = 'section';
      let base = s, i = 1;
      while (used.has(s) || document.getElementById(s)) {
        s = base + '-' + i++;
      }
      used.add(s);
      return s;
    }
    headings.forEach(h => {
      const inner = h.el.querySelector('.mw-headline');
      if (inner && inner.id) {
        h.el.id = inner.id;
      }
      if (!h.el.id) {
        h.el.id = slugify(h.text);
      } else {
        h.el.id = h.el.id.trim().replace(/\s+/g, '-');
        if (used.has(h.el.id)) h.el.id = slugify(h.text);
        else used.add(h.el.id);
      }
      h.id = h.el.id;
    });
  }

  // Build TOC with +/- buttons for sublists
  function buildTOC(headings) {
    const nav = document.createElement('nav');
    nav.id = TOC_ID;
    nav.setAttribute('aria-label', 'Table of contents');

    // header inside TOC (title + close icon for mobile)
    const header = document.createElement('div');
    header.className = 'pcgw-toc-header';

    const title = document.createElement('div');
    title.className = 'pcgw-toc-title';
    title.textContent = 'Table of contents';

    const closeBtn = document.createElement('button');
    closeBtn.className = 'pcgw-toc-close';
    closeBtn.title = 'Fermer';
    closeBtn.innerHTML = '✕';

    header.appendChild(title);
    header.appendChild(closeBtn);
    nav.appendChild(header);

    const ol = document.createElement('ol');
    ol.className = 'pcgw-toc-list';
    nav.appendChild(ol);

    // Construire des listes imbriquées avec une pile
    const stack = [{ level: 1, node: ol }];
    headings.forEach((h, idx) => {
      const effective = Math.min(4, Math.max(2, h.level)); // 2..4
      while (stack.length && stack[stack.length - 1].level >= effective) stack.pop();
      const parent = stack[stack.length - 1].node;
      const li = document.createElement('li');
      li.className = 'pcgw-toc-item pcgw-level-' + (effective - 1);
      li.dataset.index = (idx + 1);

      const wrapper = document.createElement('div');
      wrapper.className = 'pcgw-item-wrapper';

      // placeholder for collapse button if sublist exists — we'll add after building children
      const link = document.createElement('a');
      link.href = '#' + h.id;
      link.textContent = h.text;
      link.className = 'pcgw-toc-link';
      link.addEventListener('click', e => {
        e.preventDefault();
        const target = document.getElementById(h.id);
        if (target) {
          const top = target.getBoundingClientRect().top + window.scrollY - 18;
          window.scrollTo({ top, behavior: 'smooth' });
        }
        // IMPORTANT: do not close the TOC automatically (requested behavior)

      });

      wrapper.appendChild(link);
      li.appendChild(wrapper);
      parent.appendChild(li);

      const sub = document.createElement('ol');
      sub.className = 'pcgw-toc-sublist';
      li.appendChild(sub);

      stack.push({ level: effective, node: sub });
    });

    // After construction: add +/- toggles to elements that have non-empty sublists
    Array.from(nav.querySelectorAll('.pcgw-toc-item')).forEach(li => {
      const sub = li.querySelector('.pcgw-toc-sublist');
      const wrapper = li.querySelector('.pcgw-item-wrapper');
      // if the sublist is not empty (may be empty if there are no children), add toggle
      if (sub && sub.children.length > 0) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'pcgw-subtoggle';
        btn.setAttribute('aria-expanded', 'true'); // unfolded by default
        btn.title = 'Unfold / fold';
        btn.innerHTML = '−'; // minus for expanded
        btn.addEventListener('click', (e) => {
          e.stopPropagation();
          const isOpen = btn.getAttribute('aria-expanded') === 'true';
          if (isOpen) {
            btn.setAttribute('aria-expanded', 'false');
            btn.innerHTML = '+';
            li.classList.add('pcgw-sub-collapsed');
          } else {
            btn.setAttribute('aria-expanded', 'true');
            btn.innerHTML = '−';
            li.classList.remove('pcgw-sub-collapsed');
          }
        });
        // Prepend button before link
        wrapper.insertBefore(btn, wrapper.firstChild);
      }
    });

    // closeBtn behavior (close only via button --> persists)
    closeBtn.addEventListener('click', () => {
      setStoredOpen(false);
      toggleTOC(false);
      updateTopButton(false);
    });

    return nav;
  }

  // Top button
  function ensureTopButton() {
    if (document.getElementById(TOC_BTN_ID)) return document.getElementById(TOC_BTN_ID);
    const btn = document.createElement('button');
    btn.id = TOC_BTN_ID;
    btn.type = 'button';
    btn.setAttribute('aria-expanded', 'false');
    btn.title = 'Show / hide table of contents';
    btn.innerHTML = `<span class="pcgw-btn-icon" aria-hidden="true">📑</span><span class="pcgw-btn-label">Table of contents ▾</span>`;
    btn.addEventListener('click', () => {
      const open = !document.body.classList.contains('pcgw-toc-open');
      setStoredOpen(open);
      toggleTOC(open);
      btn.setAttribute('aria-expanded', String(open));
      const lbl = btn.querySelector('.pcgw-btn-label');
      if (lbl) lbl.textContent = open ? 'Table of contents ▴' : 'Table of contents ▾';
    });
    document.body.appendChild(btn);
    return btn;
  }

  // Persistence
  function setStoredOpen(open) {
    try { localStorage.setItem(STORAGE_KEY, open ? '1' : '0'); } catch (e) {}
  }
  function getStoredOpen(defaultOpen = true) {
    try {
      const v = localStorage.getItem(STORAGE_KEY);
      if (v === '1') return true;
      if (v === '0') return false;
      return defaultOpen;
    } catch (e) { return defaultOpen; }
  }

  function updateTopButton(open) {
    const btn = document.getElementById(TOC_BTN_ID);
    if (!btn) return;
    btn.setAttribute('aria-expanded', String(open));
    const lbl = btn.querySelector('.pcgw-btn-label');
    if (lbl) lbl.textContent = open ? 'Table of contents ▴' : 'Table of contents ▾';
  }

  function toggleTOC(open) {
    if (open) document.body.classList.add('pcgw-toc-open');
    else document.body.classList.remove('pcgw-toc-open');
  }

  // Insert TOC and initialize state (open by default but persistent)
  function insertTOC(toc) {
    const old = document.getElementById(TOC_ID);
    if (old) old.remove();
    document.body.appendChild(toc);
    ensureTopButton();

    const stored = getStoredOpen(true);
    toggleTOC(stored);
    updateTopButton(stored);

    // IMPORTANT: Remove the click closure outside the panel and ESC — closure only via the button / closeBtn
    // => No global click/keydown handler to close the TOC is added.
  }
  // Styles (including +/- buttons, hanging numbers, responsive)
  function styleTOC() {
    if (document.getElementById('pcgw-toc-style')) return;
    const css = `
:root {
  --pcgw-toc-font: ${TOC_FONT_PX}px;
  --pcgw-toc-width: ${TOC_WIDTH_PX}px;
  --pcgw-top-gap: ${TOP_GAP_PX}px;
}

/* Top button (neutral) */
#${TOC_BTN_ID} {
  position: fixed;
  top: 0;
  left: 0;
  height: 36px;
  line-height: 36px;
  padding: 0 12px;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(250,250,250,0.98));
  color: #222;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  border: none;
  border-right: 1px solid rgba(0,0,0,0.06);
  border-bottom: 1px solid rgba(0,0,0,0.06);
  z-index: 120000;
  border-radius: 0 0 6px 0;
}
#${TOC_BTN_ID}:hover { background: linear-gradient(180deg,#fff,#fbfdff); }

/* TOC panel */
#${TOC_ID} {
  position: fixed;
  left: 8px;
  top: var(--pcgw-top-gap);
  width: var(--pcgw-toc-width);
  max-height: calc(100vh - (var(--pcgw-top-gap) + 16px));
  overflow: auto;
  background: #ffffffdd;
  border: 1px solid rgba(0,0,0,0.06);
  padding: 12px 14px;
  border-radius: 8px;
  box-shadow: 0 8px 28px rgba(3,10,25,0.06);
  z-index: 110000;
  font-size: var(--pcgw-toc-font);
  line-height: 1.45;
}

/* header inside */
.pcgw-toc-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.pcgw-toc-title { font-weight:700; color:#111; font-size:1em; }
.pcgw-toc-close { display:none; border:none; background:transparent; font-size:16px; cursor:pointer; }

/* Hanging numbers via counters */
#${TOC_ID} .pcgw-toc-list,
#${TOC_ID} .pcgw-toc-sublist {
  counter-reset: item;
  margin: 0;
  padding: 0;
  list-style: none;
}
#${TOC_ID} li {
  position: relative;
  display: block;
  margin: 0.28em 0;
  padding-left: 2.6em;
  min-height: 1.2em;
  box-sizing: border-box;
  word-break: break-word;
  white-space: normal;
}
#${TOC_ID} li::before {
  counter-increment: item;
  content: counter(item) ".";
  position: absolute;
  left: 0;
  top: 0;
  width: 2.2em;
  text-align: right;
  font-weight: 600;
  color: rgba(0,0,0,0.65);
  line-height: 1.45;
  padding-right: 0.35em;
  box-sizing: border-box;
  overflow: visible;
}
#${TOC_ID} .pcgw-level-2 { padding-left: 2.6em; }
#${TOC_ID} .pcgw-level-3 { padding-left: 3.4em; }
#${TOC_ID} .pcgw-level-4 { padding-left: 4.2em; }

/* link + wrapper */
.pcgw-item-wrapper { display: inline-flex; align-items: center; gap: 8px; }
.pcgw-subtoggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  padding: 0;
  border: 1px solid rgba(0,0,0,0.08);
  background: rgba(255,255,255,0.9);
  border-radius: 3px;
  font-weight: 700;
  cursor: pointer;
  font-size: 12px;
  line-height: 1;
}
.pcgw-subtoggle:focus { outline: 2px solid rgba(0,0,0,0.12); }

/* collapsed sublist */
.pcgw-sub-collapsed > .pcgw-toc-sublist { display: none; }

/* links */
#${TOC_ID} a {
  color: inherit;
  text-decoration: none;
  display: inline-block;
  max-width: calc(var(--pcgw-toc-width) - 70px);
}
#${TOC_ID} a:hover { text-decoration: underline; color: #0645ad; }
#${TOC_ID} a.pcgw-toc-active { font-weight:700; text-decoration: underline; color: #003bb5; }

/* responsive: mobile slide-in */
@media (max-width: ${MOBILE_BREAKPOINT}px) {
  #${TOC_BTN_ID} { left: 6px; top: 6px; height: 40px; line-height: 40px; z-index:120000; }
  #${TOC_ID} {
    left: -120%;
    top: 0;
    width: 86%;
    height: 100vh;
    max-height: none;
    border-radius: 0;
    transition: left 260ms cubic-bezier(.2,.8,.2,1);
    padding-top: 18px;
  }
  body.pcgw-toc-open #${TOC_ID} { left: 0; }
  .pcgw-toc-close { display:inline-block; }
}

/* default: hide panel when closed (visibility controlled only by button or closeBtn) */
body:not(.pcgw-toc-open) #${TOC_ID} { display: none; }

/* tiny screens tweak */
@media (max-width: 420px) {
  :root { --pcgw-toc-font: 14px; }
  #${TOC_BTN_ID} { padding: 0 10px; font-size: 13px; }
}
`;
    const s = document.createElement('style');
    s.id = 'pcgw-toc-style';
    s.appendChild(document.createTextNode(css));
    document.head.appendChild(s);
  }

  // Highlighting the active section
  function activateOnScroll(headings) {
    const links = {};
    headings.forEach(h => {
      const a = document.querySelector(`#${TOC_ID} a[href="#${CSS.escape(h.id)}"]`);
      if (a) links[h.id] = a;
    });
    let last = null;
    function onScroll() {
      let current = null;
      const threshold = Math.max(100, Math.round(window.innerHeight * 0.12));
      for (let i = headings.length - 1; i >= 0; i--) {
        const h = headings[i];
        const rect = h.el.getBoundingClientRect();
        if (rect.top <= threshold) { current = h.id; break; }
      }
      if (!current && window.scrollY > 5 && headings.length) current = headings[0].id;
      if (current !== last) {
        if (last && links[last]) links[last].classList.remove('pcgw-toc-active');
        if (current && links[current]) links[current].classList.add('pcgw-toc-active');
        last = current;
      }
    }
    window.removeEventListener('scroll', window.__pcgw_toc_scroll_fn || onScroll);
    window.__pcgw_toc_scroll_fn = onScroll;
    window.addEventListener('scroll', onScroll, { passive: true });
    setTimeout(onScroll, 200);
  }

  // Build + observe
  function observeAndRun() {
    const content = findContentNode();
    if (!content) return;

    function attemptBuild() {
      const headings = collectHeadings(content);
      if (headings.length < MIN_HEADINGS) return null;
      ensureIds(headings);
      styleTOC();
      const toc = buildTOC(headings);
      insertTOC(toc);
      activateOnScroll(headings);
      return toc;
    }

    let tocNode = attemptBuild();

    const mo = new MutationObserver(() => {
      const current = collectHeadings(content);
      const need = current.length >= MIN_HEADINGS && (!document.getElementById(TOC_ID) || (tocNode && current.length !== tocNode.querySelectorAll('li').length));
      if (need) tocNode = attemptBuild();
    });
    mo.observe(content, { childList: true, subtree: true });
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', observeAndRun);
  else observeAndRun();

})();