Grokipedia Collapsible Sections

Collapse/expand sections; auto-expands the section targeted by URL hash on SPA navigation

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Grokipedia Collapsible Sections
// @namespace    https://grokipedia.com/
// @version      2.3.0
// @description  Collapse/expand sections; auto-expands the section targeted by URL hash on SPA navigation
// @author       You
// @match        https://grokipedia.com/*
// @match        https://www.grokipedia.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  /* ─── Ref-anchor guard ───────────────────────────────────────── */
  function isRefPage() {
    return /#ref-/.test(window.location.hash);
  }
  if (isRefPage()) return;

  /* ─── Styles ─────────────────────────────────────────────────── */
  const style = document.createElement('style');
  style.textContent = `
    .gw-section-body {
      overflow: hidden;
      transition: max-height 0.32s cubic-bezier(0.4, 0, 0.2, 1),
                  opacity   0.28s ease;
      max-height: 0;
      opacity: 0;
    }
    .gw-section-body.gw-open {
      max-height: 9999px;
      opacity: 1;
    }
    .gw-section-header {
      cursor: pointer;
      user-select: none;
      display: flex !important;
      align-items: center;
      justify-content: space-between;
      gap: 0.5rem;
    }
    .gw-section-header:hover { opacity: 0.8; }
    .gw-chevron {
      flex-shrink: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 1.4em;
      height: 1.4em;
      border-radius: 50%;
      background: rgba(128,128,128,0.12);
      transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
                  background 0.15s ease;
      font-size: 0.72em;
      color: currentColor;
    }
    .gw-section-header:hover .gw-chevron { background: rgba(128,128,128,0.22); }
    .gw-chevron.gw-open { transform: rotate(180deg); }
    .gw-section-header:focus-visible {
      outline: 2px solid currentColor;
      outline-offset: 3px;
      border-radius: 2px;
    }
  `;
  document.head.appendChild(style);

  /* ─── Core helpers ────────────────────────────────────────────── */
  function isSectionHeader(el) {
    return (
      el.classList.contains('overflow-hidden') &&
      el.classList.contains('border-b') &&
      (el.className.includes('1.714286em') || el.className.includes('1\\.714286em'))
    );
  }

  function containsSectionHeader(el) {
    return Array.from(el.querySelectorAll('*')).some(child => isSectionHeader(child));
  }

  function collectSectionContent(headerEl) {
    const siblings = [];
    let node = headerEl.nextElementSibling;
    while (node) {
      if (isSectionHeader(node) || containsSectionHeader(node)) break;
      siblings.push(node);
      node = node.nextElementSibling;
    }
    return siblings;
  }

  function chevronSVG() {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width',  '1em');
    svg.setAttribute('height', '1em');
    svg.setAttribute('fill',   'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2.5');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    svg.setAttribute('aria-hidden', 'true');
    const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
    poly.setAttribute('points', '6 9 12 15 18 9');
    svg.appendChild(poly);
    return svg;
  }

  /* ─── header → wrapper registry ─────────────────────────────── */
  const headerToWrapper = new WeakMap();

  /* ─── Open a body wrapper, then scroll to target after transition ── */
  function openSection(header, wrapper, scrollTarget) {
    const alreadyOpen = wrapper.classList.contains('gw-open');
    if (!alreadyOpen) {
      wrapper.classList.add('gw-open');
      header.setAttribute('aria-expanded', 'true');
      const chevron = header.querySelector('.gw-chevron');
      if (chevron) chevron.classList.add('gw-open');
    }
    if (!scrollTarget) return;
    if (alreadyOpen) {
      // Already open — content is visible, scroll immediately
      scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
    } else {
      // Wait for the max-height transition (320ms) to finish before scrolling
      // so the element has its real position in the document.
      wrapper.addEventListener('transitionend', function onEnd(e) {
        if (e.propertyName !== 'max-height') return;
        wrapper.removeEventListener('transitionend', onEnd);
        scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
      });
    }
  }

  /* ─── Expand whatever section the current hash targets ───────── */
  function expandForCurrentHash() {
    const hash = location.hash;
    if (!hash || hash === '#' || isRefPage()) return;

    const id = decodeURIComponent(hash.slice(1));
    const target = document.getElementById(id);

    if (!target) return;

    // Walk up from the anchor target until we find a section header
    // or land inside a collapsed body.
    let el = target;
    while (el && el !== document.body) {
      // Case A: we landed directly on (or walked up to) a section header
      if (isSectionHeader(el) && headerToWrapper.has(el)) {
        openSection(el, headerToWrapper.get(el), target);
        return;
      }

      // Case B: we're inside a collapsed body — its previous sibling is the header
      if (el.classList.contains('gw-section-body')) {
        const header = el.previousElementSibling;
        if (header && headerToWrapper.has(header)) {
          openSection(header, el, target);
        }
        return;
      }

      el = el.parentElement;
    }
  }

  /* ─── Build collapsible section ──────────────────────────────── */
  function wrapAndBind(header, contentEls) {
    if (contentEls.length === 0 || header.dataset.gwProcessed === 'true') return;

    const wrapper = document.createElement('div');
    wrapper.className = 'gw-section-body';
    wrapper.setAttribute('role', 'region');

    contentEls[0].parentNode.insertBefore(wrapper, contentEls[0]);
    contentEls.forEach(el => wrapper.appendChild(el));

    header.dataset.gwProcessed = 'true';
    headerToWrapper.set(header, wrapper);

    header.classList.add('gw-section-header');
    header.setAttribute('tabindex', '0');
    header.setAttribute('role', 'button');
    header.setAttribute('aria-expanded', 'false');

    const chevron = document.createElement('span');
    chevron.className = 'gw-chevron';
    chevron.appendChild(chevronSVG());
    header.appendChild(chevron);

    function toggle(e) {
      e.preventDefault();
      const isOpen = wrapper.classList.contains('gw-open');
      wrapper.classList.toggle('gw-open', !isOpen);
      chevron.classList.toggle('gw-open', !isOpen);
      header.setAttribute('aria-expanded', String(!isOpen));
    }

    header.addEventListener('click', toggle);
    header.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') toggle(e);
    });
  }

  /* ─── Cleanup (for ref pages) ────────────────────────────────── */
  function cleanup() {
    document.querySelectorAll('.gw-section-body').forEach(wrapper => {
      while (wrapper.firstChild) wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper);
      wrapper.remove();
    });
    document.querySelectorAll('.gw-section-header').forEach(header => {
      header.classList.remove('gw-section-header');
      header.removeAttribute('tabindex');
      header.removeAttribute('role');
      header.removeAttribute('aria-expanded');
      header.removeAttribute('data-gw-processed');
      const chevron = header.querySelector('.gw-chevron');
      if (chevron) chevron.remove();
    });
  }

  /* ─── Section processing ─────────────────────────────────────── */
  function processGenericSections(root) {
    Array.from(root.querySelectorAll('*'))
      .filter(isSectionHeader)
      .forEach(header => wrapAndBind(header, collectSectionContent(header)));
  }

  function processReferences() {
    const refContainer = document.getElementById('references');
    if (!refContainer) return;
    const heading = refContainer.querySelector(
      '.clear-left.overflow-hidden.border-b[class*="1.714286em"]'
    );
    if (!heading || heading.dataset.gwProcessed === 'true') return;
    const contentEls = collectSectionContent(heading);
    if (contentEls.length === 0) {
      const fallback = refContainer.querySelector('[class*="columns-1"]');
      if (fallback && fallback.parentNode === refContainer) {
        wrapAndBind(heading, [fallback]);
        return;
      }
    }
    wrapAndBind(heading, contentEls);
  }

  /* ─── SPA: intercept ALL ways the hash can change ───────────── */
  window.addEventListener('hashchange', () => {
    if (isRefPage()) { cleanup(); return; }
    expandForCurrentHash();
  });

  window.addEventListener('popstate', () => {
    if (isRefPage()) { cleanup(); return; }
    expandForCurrentHash();
  });

  ['pushState', 'replaceState'].forEach(method => {
    const orig = history[method];
    history[method] = function (...args) {
      orig.apply(this, args);
      const newUrl = args[2] ? String(args[2]) : location.href;
      const newHash = newUrl.includes('#') ? '#' + newUrl.split('#')[1] : '';
      if (newHash && !/#ref-/.test(newHash)) {
        setTimeout(expandForCurrentHash, 0);
      }
    };
  });

  /* ─── Main init ──────────────────────────────────────────────── */
  function init() {
    if (isRefPage()) { cleanup(); return; }
    processGenericSections(document.body);
    processReferences();
    if (location.hash) expandForCurrentHash();
  }

  let debounceTimer;
  const observer = new MutationObserver(() => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      if (!isRefPage()) {
        processGenericSections(document.body);
        processReferences();
      }
    }, 400);
  });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
      init();
      observer.observe(document.body, { childList: true, subtree: true });
    });
  } else {
    init();
    observer.observe(document.body, { childList: true, subtree: true });
  }
})();