Jump to content

MediaWiki:Gadget-readerchrome.js

From Anandamakaranda
Revision as of 17:55, 2 July 2026 by Vaishnavi (talk | contribs) (Created page with "/** * readerChrome.js β€” site-wide reader chrome (NOT part of the editor). * * Provides two always-available reader aids that used to live inside the * Editor extension's siteNav.js: * β€’ [πŸ“š] document navigator (bottom-right FAB + panel) * β€’ [↑] scroll-to-top (appears after 300px scroll) * * Why it's separate from the editor: these are reading aids, useful on every * page for every visitor β€” they have nothing to do with editing. Keeping th...")
(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.
/**
 * readerChrome.js β€” site-wide reader chrome (NOT part of the editor).
 *
 * Provides two always-available reader aids that used to live inside the
 * Editor extension's siteNav.js:
 *   β€’ [πŸ“š] document navigator  (bottom-right FAB + panel)
 *   β€’ [↑]  scroll-to-top       (appears after 300px scroll)
 *
 * Why it's separate from the editor: these are reading aids, useful on every
 * page for every visitor β€” they have nothing to do with editing. Keeping them
 * in the editor module meant they only made sense in edit context and leaked
 * onto all pages awkwardly. This module owns them, loads site-wide, and the
 * editor no longer touches them.
 *
 * Docslist is DERIVED from the JSON: namespace (NS 3000): every document has a
 * JSON: page, so listing that namespace is the authoritative, self-maintaining
 * document list. When the editor creates a new document (writes its JSON page),
 * it appears here automatically β€” no hardcoded list to maintain.
 *
 * Install: add as a Gadget (or load via MediaWiki:Common.js /
 * wgResourceModules) so it runs on every page. Requires jQuery + mediawiki.util
 * (both default). The FAB image /images/docs.svg is optional (emoji fallback).
 */
( function () {
	'use strict';

	/* The JSON: namespace id β€” the authoritative document catalogue. */
	var JSON_NS = 3000;

	function boot() {
		if ( document.getElementById( 'se-docnav' ) ) return;

		var FAB_BG       = '#c0521a';
		var FAB_BG_HOVER = '#a0410e';
		var FAB_RIGHT    = '20px';

		/* ── Styles (scoped, class-based, no page-CSS dependency) ─────── */
		var css = [
			'#se-docnav-btn{',
			'  position:fixed;bottom:22px;right:' + FAB_RIGHT + ';z-index:9998;',
			'  width:48px;height:48px;border-radius:50%;',
			'  background:' + FAB_BG + ';border:none;cursor:pointer;padding:0;',
			'  box-shadow:0 3px 12px rgba(0,0,0,0.22);',
			'  display:flex;align-items:center;justify-content:center;',
			'  transition:background .15s,transform .15s,box-shadow .15s;user-select:none;',
			'}',
			'#se-docnav-btn:hover{background:' + FAB_BG_HOVER + ';transform:scale(1.07);box-shadow:0 4px 16px rgba(0,0,0,0.3);}',
			'#se-docnav-btn .se-fab-img{width:22px;height:22px;filter:brightness(0) invert(1);display:block;}',
			'#se-docnav-btn .se-fab-emoji{font-size:20px;line-height:1;}',

			'#se-scrolltop{',
			'  position:fixed;bottom:78px;right:' + FAB_RIGHT + ';z-index:9997;',
			'  width:48px;height:48px;border-radius:50%;',
			'  background:' + FAB_BG + ';border:none;cursor:pointer;padding:0;',
			'  box-shadow:0 3px 12px rgba(0,0,0,0.22);',
			'  display:flex;align-items:center;justify-content:center;',
			'  opacity:0;pointer-events:none;',
			'  transition:opacity .25s,transform .15s,background .15s;transform:translateY(8px);',
			'}',
			'#se-scrolltop.se-scrolltop-visible{opacity:1;pointer-events:auto;transform:translateY(0);}',
			'#se-scrolltop:hover{background:' + FAB_BG_HOVER + ';transform:scale(1.07);}',
			'#se-scrolltop svg{pointer-events:none;}',

			'#se-docnav-btn .se-tip,#se-scrolltop .se-tip{',
			'  position:absolute;right:calc(100% + 10px);top:50%;transform:translateY(-50%);',
			'  white-space:nowrap;background:rgba(30,30,30,.82);color:#fff;',
			'  font-size:11px;font-family:system-ui,sans-serif;font-weight:500;',
			'  padding:3px 9px;border-radius:999px;pointer-events:none;opacity:0;transition:opacity .18s;',
			'}',
			'#se-docnav-btn:hover .se-tip,#se-scrolltop:hover .se-tip{opacity:1;}',

			'#se-docnav{',
			'  position:fixed;bottom:134px;right:' + FAB_RIGHT + ';z-index:9999;',
			'  width:300px;max-height:calc(100vh - 164px);',
			'  background:#fff;border-radius:14px;',
			'  box-shadow:0 8px 32px rgba(0,0,0,0.18),0 1.5px 4px rgba(0,0,0,0.08);',
			'  border:0.5px solid #e0e0e0;',
			'  display:none;flex-direction:column;overflow:hidden;',
			'  font-family:system-ui,sans-serif;transform-origin:bottom right;animation:se-dn-pop .18s ease;',
			'}',
			'@keyframes se-dn-pop{from{opacity:0;transform:scale(.93) translateY(6px)}to{opacity:1;transform:none}}',
			'#se-docnav.se-dn-open{display:flex;}',
			'#se-docnav .se-dn-head{display:flex;align-items:center;justify-content:space-between;padding:13px 16px 10px;border-bottom:.5px solid #eee;flex-shrink:0;}',
			'#se-docnav .se-dn-head-title{font-size:13px;font-weight:700;color:#1a2e40;letter-spacing:.01em;}',
			'#se-docnav .se-dn-close{background:none;border:none;cursor:pointer;padding:2px 5px;font-size:16px;color:#999;line-height:1;border-radius:4px;transition:background .12s,color .12s;}',
			'#se-docnav .se-dn-close:hover{background:#f1f3f4;color:#333;}',
			'#se-docnav .se-dn-search{padding:9px 12px;border-bottom:.5px solid #eee;flex-shrink:0;}',
			'#se-docnav .se-dn-search input{width:100%;padding:6px 10px;border:.5px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;color:#333;outline:none;box-sizing:border-box;transition:border-color .15s;}',
			'#se-docnav .se-dn-search input:focus{border-color:' + FAB_BG + ';}',
			'#se-docnav .se-dn-list{overflow-y:auto;flex:1;padding:6px 0;}',
			'#se-docnav .se-dn-item{display:flex;align-items:center;gap:10px;padding:9px 16px;cursor:pointer;font-size:13.5px;color:#222;border-left:3px solid transparent;transition:background .1s,border-color .1s;text-decoration:none;}',
			'#se-docnav .se-dn-item:hover{background:#fdf3ed;border-left-color:' + FAB_BG + ';}',
			'#se-docnav .se-dn-item.se-dn-current{background:#fdf3ed;border-left-color:' + FAB_BG + ';font-weight:600;color:' + FAB_BG + ';}',
			'#se-docnav .se-dn-icon{font-size:15px;flex-shrink:0;opacity:.7;}',
			'#se-docnav .se-dn-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
			'#se-docnav .se-dn-status{padding:18px 16px;text-align:center;color:#999;font-size:13px;}',
			'#se-docnav .se-dn-footer{border-top:.5px solid #eee;flex-shrink:0;}',
			'#se-docnav .se-dn-footer a{display:flex;align-items:center;gap:8px;padding:10px 16px;font-size:12.5px;color:#666;text-decoration:none;transition:background .12s,color .12s;}',
			'#se-docnav .se-dn-footer a:hover{background:#fdf3ed;color:' + FAB_BG + ';}',

			'@media(max-width:768px){',
			'  #se-docnav-btn{bottom:18px;right:16px;width:44px;height:44px;}',
			'  #se-scrolltop{bottom:74px;right:16px;width:44px;height:44px;}',
			'  #se-docnav{right:16px;width:calc(100vw - 32px);bottom:128px;}',
			'}',
			'@media print{#se-docnav,#se-docnav-btn,#se-scrolltop{display:none!important}}'
		].join( '' );

		var styleEl = document.createElement( 'style' );
		styleEl.id  = 'se-readerchrome-css';
		styleEl.textContent = css;
		document.head.appendChild( styleEl );

		/* ── Build DOM ───────────────────────────────────────── */
		var mainPage = ( mw.config.get( 'wgArticlePath' ) || '/wiki/$1' )
			.replace( '$1', encodeURIComponent( mw.config.get( 'wgMainPageTitle' ) || 'Main_Page' ) );
		var currentTitle = mw.config.get( 'wgPageName' ) || '';
		var currentRoot  = currentTitle.split( '/' )[ 0 ];

		var panel = document.createElement( 'div' );
		panel.id  = 'se-docnav';
		panel.innerHTML = [
			'<div class="se-dn-head">',
			'  <span class="se-dn-head-title">πŸ“š Documents</span>',
			'  <button class="se-dn-close" title="Close">βœ•</button>',
			'</div>',
			'<div class="se-dn-search"><input type="text" placeholder="Search documents…" id="se-dn-input" autocomplete="off"></div>',
			'<div class="se-dn-list" id="se-dn-list"><div class="se-dn-status">Loading…</div></div>',
			'<div class="se-dn-footer"><a href="' + mainPage + '">βŒ‚&ensp;Go to Main Page</a></div>'
		].join( '' );

		var triggerBtn = document.createElement( 'button' );
		triggerBtn.id    = 'se-docnav-btn';
		triggerBtn.title = 'Browse documents';
		triggerBtn.setAttribute( 'aria-label', 'Browse documents' );
		triggerBtn.innerHTML =
			'<img class="se-fab-img" src="/images/docs.svg" alt="" aria-hidden="true" ' +
			'     onerror="this.outerHTML=\'<span class=&quot;se-fab-emoji&quot; aria-hidden=&quot;true&quot;>πŸ“š</span>\'">' +
			'<span class="se-tip">Browse documents</span>';

		var scrollBtn = document.createElement( 'button' );
		scrollBtn.id    = 'se-scrolltop';
		scrollBtn.title = 'Back to top';
		scrollBtn.setAttribute( 'aria-label', 'Scroll to top' );
		scrollBtn.innerHTML =
			'<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
			'  <path d="M10 3.5L3.5 11H7.5V16.5H12.5V11H16.5L10 3.5Z" fill="white"/>' +
			'</svg>' +
			'<span class="se-tip">Back to top</span>';

		document.body.appendChild( panel );
		document.body.appendChild( triggerBtn );
		document.body.appendChild( scrollBtn );

		/* ── Docs logic β€” DERIVED from the JSON: namespace ───── */
		var allDocs = [], loaded = false, open = false;

		function artPath( title ) {
			return ( mw.config.get( 'wgArticlePath' ) || '/wiki/$1' ).replace( '$1', title.replace( / /g, '_' ) );
		}

		function renderList( filter ) {
			var list  = document.getElementById( 'se-dn-list' );
			var q     = ( filter || '' ).toLowerCase().trim();
			var shown = q ? allDocs.filter( function ( d ) {
				return d.title.toLowerCase().indexOf( q ) !== -1 ||
					( d.display && d.display.toLowerCase().indexOf( q ) !== -1 );
			} ) : allDocs;
			if ( !shown.length ) {
				list.innerHTML = '<div class="se-dn-status">' +
					( q ? 'No results for "' + q + '"' : 'No documents found.' ) + '</div>';
				return;
			}
			var html = '';
			shown.forEach( function ( d ) {
				var cur = d.title === currentRoot;
				html += '<a class="se-dn-item' + ( cur ? ' se-dn-current' : '' ) + '" href="' + d.href + '" title="' + d.title + '">' +
					'<span class="se-dn-icon">πŸ“–</span>' +
					'<span class="se-dn-name">' + d.display + '</span>' +
					( cur ? '<span style="font-size:11px;color:' + FAB_BG + ';flex-shrink:0">← here</span>' : '' ) +
					'</a>';
			} );
			list.innerHTML = html;
		}

		function loadDocs() {
			if ( loaded ) return;
			loaded = true;
			var accumulated = [];
			function fetchBatch( apcontinue ) {
				/* List the JSON: namespace β€” the authoritative doc catalogue.
				   Each JSON: page title IS a document slug, so new docs created
				   by the editor appear here automatically. */
				var params = {
					action: 'query', list: 'allpages',
					apnamespace: JSON_NS, aplimit: 'max', format: 'json'
				};
				if ( apcontinue ) params.apcontinue = apcontinue;
				$.ajax( { url: mw.util.wikiScript( 'api' ), data: params, dataType: 'json' } )
					.then( function ( data ) {
						var pages = ( data.query && data.query.allpages ) || [];
						pages.forEach( function ( p ) {
							/* p.title is like "JSON:Anuvyakhyana" β€” strip the ns
							   prefix to get the reader page slug. Only top-level
							   docs (no subpage slash after the slug). */
							var slug = p.title.replace( /^[^:]+:/, '' );
							if ( slug.indexOf( '/' ) === -1 ) {
								accumulated.push( {
									title: slug,
									display: slug.replace( /_/g, ' ' ),
									href: artPath( slug )
								} );
							}
						} );
						if ( data.continue && data.continue.apcontinue ) {
							fetchBatch( data.continue.apcontinue );
						} else {
							accumulated.sort( function ( a, b ) { return a.title.localeCompare( b.title ); } );
							allDocs = accumulated;
							renderList( '' );
						}
					} ).fail( function () {
						var list = document.getElementById( 'se-dn-list' );
						if ( list ) list.innerHTML = '<div class="se-dn-status">Could not load documents.</div>';
					} );
			}
			fetchBatch( null );
		}

		function openPanel() {
			open = true; panel.classList.add( 'se-dn-open' ); loadDocs();
			setTimeout( function () { var i = document.getElementById( 'se-dn-input' ); if ( i ) i.focus(); }, 60 );
		}
		function closePanel() { open = false; panel.classList.remove( 'se-dn-open' ); }

		triggerBtn.addEventListener( 'click', function ( e ) { e.stopPropagation(); open ? closePanel() : openPanel(); } );
		panel.querySelector( '.se-dn-close' ).addEventListener( 'click', closePanel );
		document.addEventListener( 'click', function ( e ) { if ( open && !panel.contains( e.target ) && e.target !== triggerBtn ) closePanel(); } );
		document.addEventListener( 'input', function ( e ) { if ( e.target && e.target.id === 'se-dn-input' ) renderList( e.target.value ); } );
		document.addEventListener( 'keydown', function ( e ) { if ( open && ( e.key === 'Escape' || e.keyCode === 27 ) ) closePanel(); } );

		/* ── Scroll-to-top (window + Minerva #mw-mf-viewport) ── */
		var _scrollRaf = null;
		function updateScrollBtn( y ) { scrollBtn.classList.toggle( 'se-scrolltop-visible', y > 300 ); }
		function onWindowScroll() {
			if ( _scrollRaf ) return;
			_scrollRaf = requestAnimationFrame( function () {
				_scrollRaf = null;
				updateScrollBtn( window.scrollY || window.pageYOffset || 0 );
			} );
		}
		function onViewportScroll() {
			if ( _scrollRaf ) return;
			_scrollRaf = requestAnimationFrame( function () {
				_scrollRaf = null;
				var vp = document.getElementById( 'mw-mf-viewport' );
				updateScrollBtn( vp ? vp.scrollTop : 0 );
			} );
		}
		scrollBtn.addEventListener( 'click', function () {
			var vp = document.getElementById( 'mw-mf-viewport' );
			if ( vp && vp.scrollTop > 0 ) { vp.scrollTo( { top: 0, behavior: 'smooth' } ); }
			else { window.scrollTo( { top: 0, behavior: 'smooth' } ); }
		} );
		window.addEventListener( 'scroll', onWindowScroll, { passive: true } );
		var _vpEl = document.getElementById( 'mw-mf-viewport' );
		if ( _vpEl ) {
			_vpEl.addEventListener( 'scroll', onViewportScroll, { passive: true } );
		} else {
			setTimeout( function () {
				var vp = document.getElementById( 'mw-mf-viewport' );
				if ( vp ) vp.addEventListener( 'scroll', onViewportScroll, { passive: true } );
			}, 500 );
		}
		onWindowScroll();
		onViewportScroll();
	}

	if ( window.mw && mw.hook ) {
		mw.hook( 'wikipage.content' ).add( boot );
	} else if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', boot );
	} else {
		boot();
	}

}() );