Jump to content

MediaWiki:Common.js

From Cinnamon Toast Crunch
Revision as of 08:44, 1 May 2026 by Cinnadust (talk | contribs) (Created page with "/* ============================================================ GOLDEN GATE BRIDGE — MediaWiki Theme JavaScript Paste into: MediaWiki:Common.js Features: 1. Reading progress bar 2. Fog canvas animation 3. Scroll-reveal for content sections 4. Back-to-top button 5. Anchor links on headings 6. Time-based dynamic header gradient 7. Sidebar cable decoration 8. Smooth image lazy-load effect ============================...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* ============================================================
   GOLDEN GATE BRIDGE — MediaWiki Theme JavaScript
   Paste into: MediaWiki:Common.js
   
   Features:
     1. Reading progress bar
     2. Fog canvas animation
     3. Scroll-reveal for content sections
     4. Back-to-top button
     5. Anchor links on headings
     6. Time-based dynamic header gradient
     7. Sidebar cable decoration
     8. Smooth image lazy-load effect
   ============================================================ */

( function () {
  'use strict';

  /* ─────────────────────────────────────────────────────────
     1. READING PROGRESS BAR
     A slim orange bar at the very top of the viewport that
     fills as the user scrolls through the page.
  ───────────────────────────────────────────────────────── */
  function initProgress() {
    var bar = document.createElement( 'div' );
    bar.id = 'ggb-progress';
    document.body.insertBefore( bar, document.body.firstChild );

    function update() {
      var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
      var docH = document.documentElement.scrollHeight - window.innerHeight;
      var pct  = docH > 0 ? Math.min( ( scrollTop / docH ) * 100, 100 ) : 0;
      bar.style.width = pct + '%';
    }

    window.addEventListener( 'scroll', update, { passive: true } );
    update();
  }

  /* ─────────────────────────────────────────────────────────
     2. FOG CANVAS ANIMATION
     A canvas layered over the page renders softly drifting
     fog puffs that become visible as the user scrolls — 
     simulating San Francisco's famous marine layer.
  ───────────────────────────────────────────────────────── */
  function initFog() {
    var canvas = document.createElement( 'canvas' );
    canvas.id = 'ggb-fog-canvas';
    Object.assign( canvas.style, {
      position:      'fixed',
      top:           '0',
      left:          '0',
      width:         '100%',
      height:        '100%',
      pointerEvents: 'none',
      zIndex:        '9998',
      opacity:       '0',
      transition:    'opacity 2.5s ease',
    } );
    document.body.appendChild( canvas );

    var ctx = canvas.getContext( '2d' );
    var W, H, particles = [], raf;

    function resize() {
      W = canvas.width  = window.innerWidth;
      H = canvas.height = window.innerHeight;
    }

    // A single fog puff
    function Puff() {
      var self = this;
      self.reset = function () {
        self.x     = Math.random() * W * 1.5 - W * 0.25;
        self.y     = Math.random() * H;
        self.r     = 100 + Math.random() * 220;
        self.alpha = 0.008 + Math.random() * 0.028;
        self.vx    = -0.06 - Math.random() * 0.1;
        self.vy    = -0.01 + Math.random() * 0.018;
      };
      self.reset();

      self.update = function () {
        self.x += self.vx;
        self.y += self.vy;
        if ( self.x + self.r < 0 ) self.reset();
      };

      self.draw = function () {
        var g = ctx.createRadialGradient( self.x, self.y, 0, self.x, self.y, self.r );
        g.addColorStop( 0,   'rgba(245,242,236,' + self.alpha + ')' );
        g.addColorStop( 0.5, 'rgba(240,236,228,' + ( self.alpha * 0.5 ) + ')' );
        g.addColorStop( 1,   'rgba(245,242,236,0)' );
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc( self.x, self.y, self.r, 0, Math.PI * 2 );
        ctx.fill();
      };
    }

    function loop() {
      ctx.clearRect( 0, 0, W, H );
      for ( var i = 0; i < particles.length; i++ ) {
        particles[ i ].update();
        particles[ i ].draw();
      }
      raf = requestAnimationFrame( loop );
    }

    function init() {
      resize();
      particles = [];
      for ( var i = 0; i < 24; i++ ) {
        var p = new Puff();
        // Stagger starting positions so they don't all start at the same x
        p.x = Math.random() * W;
        particles.push( p );
      }
      loop();
    }

    window.addEventListener( 'resize', resize, { passive: true } );
    init();

    // Fog fades in after first scroll, fades out back to top
    var fogVisible = false;
    window.addEventListener( 'scroll', function () {
      var shouldShow = window.pageYOffset > 250;
      if ( shouldShow !== fogVisible ) {
        fogVisible = shouldShow;
        canvas.style.opacity = fogVisible ? '1' : '0';
      }
    }, { passive: true } );
  }

  /* ─────────────────────────────────────────────────────────
     3. SCROLL REVEAL
     Uses IntersectionObserver to slide content elements in
     as they enter the viewport — staggered for a wave feel.
  ───────────────────────────────────────────────────────── */
  function initScrollReveal() {
    if ( !window.IntersectionObserver ) return;

    var selectors = [
      '.mw-body-content h2',
      '.mw-body-content h3',
      '.mw-body-content table.wikitable',
      '.mw-body-content .thumb',
      '.mw-body-content blockquote',
      '.mw-body-content .infobox',
      '.mw-body-content .hatnote',
      '#toc',
    ].join( ', ' );

    var targets = document.querySelectorAll( selectors );

    var observer = new IntersectionObserver( function ( entries ) {
      entries.forEach( function ( entry, i ) {
        if ( entry.isIntersecting ) {
          var el = entry.target;
          var delay = ( i % 4 ) * 70;
          setTimeout( function () {
            el.classList.add( 'ggb-in' );
            if ( i % 2 === 1 ) el.classList.add( 'ggb-in-delay' );
          }, delay );
          observer.unobserve( el );
        }
      } );
    }, {
      threshold:  0.1,
      rootMargin: '0px 0px -50px 0px',
    } );

    targets.forEach( function ( el ) {
      el.classList.add( 'ggb-reveal' );
      observer.observe( el );
    } );
  }

  /* ─────────────────────────────────────────────────────────
     4. BACK-TO-TOP BUTTON
     An orange circular button appears after scrolling 400px.
     Clicking it smoothly returns to the top of the page.
  ───────────────────────────────────────────────────────── */
  function initBackToTop() {
    var btn = document.createElement( 'button' );
    btn.id          = 'ggb-top-btn';
    btn.title       = 'Back to top';
    btn.textContent = '↑';
    btn.setAttribute( 'aria-label', 'Back to top' );
    document.body.appendChild( btn );

    btn.addEventListener( 'click', function () {
      window.scrollTo( { top: 0, behavior: 'smooth' } );
    } );

    window.addEventListener( 'scroll', function () {
      if ( window.pageYOffset > 400 ) {
        btn.classList.add( 'visible' );
      } else {
        btn.classList.remove( 'visible' );
      }
    }, { passive: true } );
  }

  /* ─────────────────────────────────────────────────────────
     5. HEADING ANCHOR LINKS
     Adds a ⚓ anchor icon that appears on heading hover,
     making it easy to copy links to specific sections.
  ───────────────────────────────────────────────────────── */
  function initAnchorLinks() {
    var headlines = document.querySelectorAll( '.mw-headline[id]' );

    headlines.forEach( function ( el ) {
      var icon = document.createElement( 'a' );
      icon.href      = '#' + el.id;
      icon.className = 'ggb-anchor-link';
      icon.textContent = ' ¶';
      icon.title     = 'Link to this section';
      icon.setAttribute( 'aria-label', 'Link to section: ' + el.textContent );
      Object.assign( icon.style, {
        color:          'rgba(200,92,43,0)',
        fontSize:       '0.65em',
        fontFamily:     "'Josefin Sans', sans-serif",
        letterSpacing:  '0',
        marginLeft:     '8px',
        textDecoration: 'none',
        transition:     'color 0.2s ease',
        verticalAlign:  'middle',
      } );

      var heading = el.parentElement;
      heading.addEventListener( 'mouseenter', function () {
        icon.style.color = 'rgba(200,92,43,0.55)';
      } );
      heading.addEventListener( 'mouseleave', function () {
        icon.style.color = 'rgba(200,92,43,0)';
      } );
      icon.addEventListener( 'mouseenter', function () {
        this.style.color = '#C85C2B';
      } );
      icon.addEventListener( 'mouseleave', function () {
        this.style.color = 'rgba(200,92,43,0.55)';
      } );

      el.appendChild( icon );
    } );
  }

  /* ─────────────────────────────────────────────────────────
     6. TIME-BASED DYNAMIC GRADIENT
     The header and page background shift colour based on
     the time of day — from cold foggy dawn to warm sunset
     to deep bay-at-night, just like the real bridge.
  ───────────────────────────────────────────────────────── */
  function initDynamicGradient() {
    var hour = new Date().getHours();
    var gradient;

    if ( hour >= 5 && hour < 8 ) {
      // Pre-dawn / dawn: lavender sky, orange horizon
      gradient = 'linear-gradient(150deg, #2A1A38 0%, #7A3020 45%, #C85C2B 100%)';
    } else if ( hour >= 8 && hour < 11 ) {
      // Morning: fog burning off, cool deep navy
      gradient = 'linear-gradient(150deg, #0B1B2B 0%, #1A3A58 60%, #162840 100%)';
    } else if ( hour >= 11 && hour < 16 ) {
      // Midday: classic deep bay navy
      gradient = 'linear-gradient(150deg, #0B1B2B 0%, #162840 50%, #0B1B2B 100%)';
    } else if ( hour >= 16 && hour < 19 ) {
      // Golden hour: warm amber bleeds in from right
      gradient = 'linear-gradient(150deg, #0B1B2B 0%, #5A2010 55%, #C9A84C 100%)';
    } else if ( hour >= 19 && hour < 21 ) {
      // Sunset: deep vermillion, spectacular
      gradient = 'linear-gradient(150deg, #180808 0%, #6A2010 35%, #C85C2B 80%, #C9A84C 100%)';
    } else {
      // Night: starless black bay
      gradient = 'linear-gradient(150deg, #04090E 0%, #0B1B2B 55%, #0F2235 100%)';
    }

    // Apply to header elements
    var targets = [
      document.getElementById( 'mw-head' ),
      document.getElementById( 'mw-head-base' ),
      document.getElementById( 'mw-page-base' ),
      document.querySelector( '.vector-header' ),
    ];

    targets.forEach( function ( el ) {
      if ( el ) el.style.background = gradient;
    } );

    // Also subtly tint the sidebar top
    var panel = document.getElementById( 'mw-panel' ) ||
                document.querySelector( '.vector-column-start' );
    if ( panel ) {
      panel.style.background =
        'linear-gradient(180deg, #0D1F30 0%, #0B1B2B 20%)';
    }
  }

  /* ─────────────────────────────────────────────────────────
     7. SIDEBAR CABLE DECORATION
     Injects an animated SVG suspension cable graphic into
     the sidebar — a subtle architectural nod to the bridge.
  ───────────────────────────────────────────────────────── */
  function initSidebarCable() {
    var panel = document.getElementById( 'mw-panel' ) ||
                document.querySelector( '.vector-column-start' );
    if ( !panel ) return;

    var svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
    svg.setAttribute( 'viewBox', '0 0 20 300' );
    svg.setAttribute( 'preserveAspectRatio', 'none' );
    Object.assign( svg.style, {
      position:   'absolute',
      top:        '0',
      right:      '0',
      width:      '20px',
      height:     '100%',
      opacity:    '0.6',
      pointerEvents: 'none',
    } );

    // Main cable strand
    var cable = document.createElementNS( 'http://www.w3.org/2000/svg', 'line' );
    cable.setAttribute( 'x1', '10' ); cable.setAttribute( 'y1', '0' );
    cable.setAttribute( 'x2', '10' ); cable.setAttribute( 'y2', '300' );
    cable.setAttribute( 'stroke', '#C85C2B' );
    cable.setAttribute( 'stroke-width', '1.5' );
    cable.setAttribute( 'stroke-opacity', '0.5' );
    svg.appendChild( cable );

    // Horizontal suspender ticks
    for ( var y = 20; y < 300; y += 28 ) {
      var tick = document.createElementNS( 'http://www.w3.org/2000/svg', 'line' );
      tick.setAttribute( 'x1', '6' );  tick.setAttribute( 'y1', String( y ) );
      tick.setAttribute( 'x2', '14' ); tick.setAttribute( 'y2', String( y ) );
      tick.setAttribute( 'stroke', '#C85C2B' );
      tick.setAttribute( 'stroke-width', '0.8' );
      tick.setAttribute( 'stroke-opacity', '0.35' );
      svg.appendChild( tick );
    }

    // Animate: gentle cable-sway using CSS animation on the element
    var style = document.createElement( 'style' );
    style.textContent = [
      '@keyframes ggbCableSway {',
      '  0%,100% { transform: scaleY(1.0) translateY(0px); }',
      '  50%      { transform: scaleY(1.002) translateY(-2px); }',
      '}',
      '#ggb-cable-svg {',
      '  animation: ggbCableSway 6s ease-in-out infinite;',
      '  transform-origin: top center;',
      '}',
    ].join( '\n' );
    document.head.appendChild( style );
    svg.id = 'ggb-cable-svg';

    panel.style.position = 'relative';
    panel.style.overflow = 'hidden';
    panel.appendChild( svg );
  }

  /* ─────────────────────────────────────────────────────────
     8. IMAGE LAZY-LOAD FADE
     Images in the article content fade in as they load,
     rather than popping in abruptly.
  ───────────────────────────────────────────────────────── */
  function initImageFade() {
    var images = document.querySelectorAll( '.mw-body-content img' );
    var fadeStyle = document.createElement( 'style' );
    fadeStyle.textContent = [
      '.mw-body-content img { opacity: 0; transition: opacity 0.6s ease; }',
      '.mw-body-content img.ggb-img-loaded { opacity: 1; }',
    ].join( '\n' );
    document.head.appendChild( fadeStyle );

    images.forEach( function ( img ) {
      if ( img.complete ) {
        img.classList.add( 'ggb-img-loaded' );
      } else {
        img.addEventListener( 'load', function () {
          img.classList.add( 'ggb-img-loaded' );
        } );
        img.addEventListener( 'error', function () {
          img.classList.add( 'ggb-img-loaded' ); // show broken icon
        } );
      }
    } );
  }

  /* ─────────────────────────────────────────────────────────
     9. ACTIVE TOC HIGHLIGHT
     Highlights the current section in the Table of Contents
     as the user scrolls through the article.
  ───────────────────────────────────────────────────────── */
  function initTocHighlight() {
    var toc = document.getElementById( 'toc' ) || document.querySelector( '.toc' );
    if ( !toc ) return;

    var headings = Array.from(
      document.querySelectorAll( '.mw-body-content h2[id], .mw-body-content h3[id]' )
    );
    if ( !headings.length ) return;

    // Inject highlight style
    var style = document.createElement( 'style' );
    style.textContent = [
      '.toc a.ggb-toc-active {',
      '  color: #C85C2B !important;',
      '  font-weight: 600;',
      '  padding-left: 4px;',
      '  border-left: 2px solid #C85C2B;',
      '}',
    ].join( '\n' );
    document.head.appendChild( style );

    function onScroll() {
      var scrollY = window.pageYOffset + 100;
      var current = headings[ 0 ];

      for ( var i = 0; i < headings.length; i++ ) {
        if ( headings[ i ].getBoundingClientRect().top + window.pageYOffset <= scrollY ) {
          current = headings[ i ];
        }
      }

      // Find the matching TOC link
      var id = current.id || ( current.querySelector( '.mw-headline' ) && current.querySelector( '.mw-headline' ).id );
      if ( !id ) return;

      toc.querySelectorAll( 'a' ).forEach( function ( a ) {
        a.classList.remove( 'ggb-toc-active' );
      } );

      var active = toc.querySelector( 'a[href="#' + id + '"]' );
      if ( active ) active.classList.add( 'ggb-toc-active' );
    }

    window.addEventListener( 'scroll', onScroll, { passive: true } );
    onScroll();
  }

  /* ─────────────────────────────────────────────────────────
     10. SEARCH BOX ENHANCEMENT
     Adds a keyboard shortcut (/) to focus the search box,
     and shows a subtle hint label near the input.
  ───────────────────────────────────────────────────────── */
  function initSearchShortcut() {
    var searchInput = document.getElementById( 'searchInput' ) ||
                      document.querySelector( '.vector-search-box input[type="search"]' );
    if ( !searchInput ) return;

    // Keyboard shortcut: / focuses search (unless user is in an input)
    document.addEventListener( 'keydown', function ( e ) {
      if ( e.key === '/' &&
           document.activeElement.tagName !== 'INPUT' &&
           document.activeElement.tagName !== 'TEXTAREA' ) {
        e.preventDefault();
        searchInput.focus();
        searchInput.select();
      }
    } );

    // ESC blurs search
    searchInput.addEventListener( 'keydown', function ( e ) {
      if ( e.key === 'Escape' ) searchInput.blur();
    } );

    // Hint label
    var hint = document.createElement( 'span' );
    hint.textContent = '/';
    Object.assign( hint.style, {
      background:    'rgba(138,155,176,0.15)',
      border:        '1px solid rgba(138,155,176,0.3)',
      borderRadius:  '3px',
      color:         'rgba(138,155,176,0.7)',
      fontFamily:    "'Josefin Sans', sans-serif",
      fontSize:      '10px',
      fontWeight:    '600',
      letterSpacing: '0.05em',
      padding:       '1px 5px',
      pointerEvents: 'none',
      position:      'absolute',
      right:         '40px',
      top:           '50%',
      transform:     'translateY(-50%)',
      transition:    'opacity 0.2s ease',
    } );

    var searchForm = searchInput.closest( 'form' ) || searchInput.parentElement;
    if ( searchForm ) {
      searchForm.style.position = 'relative';
      searchForm.appendChild( hint );
    }

    searchInput.addEventListener( 'focus', function () { hint.style.opacity = '0'; } );
    searchInput.addEventListener( 'blur',  function () { hint.style.opacity = '1'; } );
  }

  /* ─────────────────────────────────────────────────────────
     11. EXTERNAL LINK ICON
     Adds a small external link indicator after links that
     point outside the wiki.
  ───────────────────────────────────────────────────────── */
  function initExternalLinkIcons() {
    var style = document.createElement( 'style' );
    style.textContent = [
      '.mw-body-content a.external::after {',
      '  content: " ↗";',
      '  color: rgba(200,92,43,0.6);',
      '  font-size: 0.75em;',
      '  vertical-align: super;',
      '  margin-left: 1px;',
      '}',
    ].join( '\n' );
    document.head.appendChild( style );
  }

  /* ─────────────────────────────────────────────────────────
     INIT — run everything after DOM is ready
  ───────────────────────────────────────────────────────── */
  function run() {
    // Core UI enhancements
    initProgress();
    initBackToTop();
    initDynamicGradient();
    initSidebarCable();

    // Content enhancements
    initScrollReveal();
    initAnchorLinks();
    initTocHighlight();
    initImageFade();

    // Search
    initSearchShortcut();

    // Link icons
    initExternalLinkIcons();

    // Fog — init last as it starts a canvas animation loop
    initFog();

    // Console credit
    if ( window.console && console.log ) {
      console.log( '%c🌉 Golden Gate Bridge Theme active', 'color:#C85C2B;font-family:Georgia,serif;font-size:14px;' );
    }
  }

  if ( document.readyState === 'loading' ) {
    document.addEventListener( 'DOMContentLoaded', run );
  } else {
    // mw.loader available: use MediaWiki's ready hook if possible
    if ( typeof mw !== 'undefined' && mw.hook ) {
      mw.hook( 'wikipage.content' ).add( function () { run(); } );
    } else {
      run();
    }
  }

} )();