|
|
| Line 1: |
Line 1: |
| /* MediaWiki:Common.js */ | | /** |
| | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab) |
| | */ |
|
| |
|
| (function () { | | /* global mw, $ */ |
| | ( function () { |
| | 'use strict'; |
|
| |
|
| var LS_SCRIPT_KEY = 'grantha_reader_script'; | | var CONTENT_SEL = '#mw-content-text'; |
| var currentScript = 'deva'; | | var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); |
| | var NT_LS_KEY = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); |
| | // Writes that have not reached the server yet. Scoped per user and per |
| | // page where it is built: the keys above are per page, and a shared |
| | // machine has more than one user. |
| | var Q_LS_KEY = 'grantha_q_v1'; |
| | var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; |
| | var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; |
| | var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?'; |
| | var currentUserEmail = ''; |
|
| |
|
| function devanagariToTamil(text) {
| | // FIXED: was `new mw.Api()` called directly and synchronously here — |
| var CONSONANTS = {
| | // at this point in MediaWiki:Common.js's own load lifecycle, |
| 'क': 'க', 'ख': 'க', 'ग': 'க', 'घ': 'க', 'ङ': 'ங',
| | // mediawiki.api (the module that provides the REAL mw.Api |
| 'च': 'ச', 'छ': 'ச', 'ज': 'ஜ', 'झ': 'ஜ', 'ञ': 'ஞ',
| | // constructor) is not guaranteed to have finished loading yet, and |
| 'ट': 'ட', 'ठ': 'ட', 'ड': 'ட', 'ढ': 'ட', 'ण': 'ண',
| | // calling the constructor before it has throws "mw.Api is not a |
| 'त': 'த', 'थ': 'த', 'द': 'த', 'ध': 'த', 'न': 'ந',
| | // constructor" synchronously. Confirmed directly via the real error |
| 'प': 'ப', 'फ': 'ப', 'ब': 'ப', 'भ': 'ப', 'म': 'ம',
| | // trace, and confirmed this is pre-existing code, not anything |
| 'य': 'ய', 'र': 'ர', 'ल': 'ல', 'ळ': 'ள', 'व': 'வ',
| | // introduced by the more recent Ullekha/tab changes. Uncaught here |
| 'श': 'ஶ', 'ष': 'ஷ', 'स': 'ஸ', 'ह': 'ஹ'
| | // (the .catch below only ever handled promise rejections, never a |
| };
| | // synchronous throw from the constructor call itself), this would |
| var DIACRITICS = {
| | // halt the REST of this script's own execution — meaning later code |
| 'ा': 'ா', 'ि': 'ி', 'ी': 'ீ', 'ु': 'ு', 'ू': 'ூ',
| | // in this same file (the tab-building logic) may never run at all on |
| 'े': 'ே', 'ै': 'ை', 'ो': 'ோ', 'ौ': 'ௌ'
| | // whatever page load hits this race. mw.loader.using() guarantees the |
| };
| | // module is actually ready before mw.Api is ever touched, regardless |
| var VOWELS = {
| | // of load timing. Verified the exact failure mode and the fix |
| 'अ': 'அ', 'आ': 'ஆ', 'इ': 'இ', 'ई': 'ஈ', 'उ': 'உ', 'ऊ': 'ஊ',
| | // directly before applying here. |
| 'ए': 'ஏ', 'ऐ': 'ஐ', 'ओ': 'ஓ', 'औ': 'ஔ', 'ऽ': 'ऽ'
| | if ( currentUser && window.mw ) { |
| };
| | mw.loader.using( 'mediawiki.api' ).then( function () { |
| var MISC = {
| | return new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 }); |
| 'ं':'ம்', 'ः':':', 'ँ':'ம்', 'ॐ':'ௐ',
| | } ).then( function (data) { |
| '०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
| | var info = data && data.query && data.query.userinfo; |
| '५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
| | if ( info && info.email ) currentUserEmail = info.email; |
| };
| | } ).catch( function () {} ); |
| var HALANTA = '्';
| | } |
| var chars = Array.from(text), result = '', i = 0;
| |
| while (i < chars.length) {
| |
| var ch = chars[i], next = chars[i + 1];
| |
| if (CONSONANTS[ch]) {
| |
| var base = CONSONANTS[ch];
| |
| if (next === HALANTA) { result += base + '்'; i += 2; }
| |
| else if (next === 'ृ') { result += base + '்ரி'; i += 2; }
| |
| else if (next === 'ॄ') { result += base + '்ரீ'; i += 2; }
| |
| else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
| |
| else { result += base; i++; } // inherent 'a'
| |
| }
| |
| else if (ch === 'ऋ') { result += 'ரி'; i++; }
| |
| else if (ch === 'ॠ') { result += 'ரீ'; i++; }
| |
| else if (VOWELS[ch]) { result += VOWELS[ch]; i++; }
| |
| else if (MISC[ch]) { result += MISC[ch]; i++; }
| |
| else { result += ch; i++; }
| |
| }
| |
| return result;
| |
| }
| |
|
| |
|
| function devanagariToIAST(text) {
| | if ( window.mw ) { |
| var CONSONANTS = {
| | var ns = mw.config.get( 'wgNamespaceNumber' ); |
| 'क': 'k', 'ख': 'kh', 'ग': 'g', 'घ': 'gh', 'ङ': 'ṅ',
| | if ( ns < 0 ) return; |
| 'च': 'c', 'छ': 'ch', 'ज': 'j', 'झ': 'jh', 'ञ': 'ñ',
| | } |
| 'ट': 'ṭ', 'ठ': 'ṭh', 'ड': 'ḍ', 'ढ': 'ḍh', 'ण': 'ṇ',
| |
| 'त': 't', 'थ': 'th', 'द': 'd', 'ध': 'dh', 'न': 'n',
| |
| 'प': 'p', 'फ': 'ph', 'ब': 'b', 'भ': 'bh', 'म': 'm',
| |
| 'य': 'y', 'र': 'r', 'ल': 'l', 'ळ': 'ḷ', 'व': 'v',
| |
| 'श': 'ś', 'ष': 'ṣ', 'स': 's', 'ह': 'h'
| |
| };
| |
| var DIACRITICS = {
| |
| 'ा': 'ā', 'ि': 'i', 'ी': 'ī', 'ु': 'u', 'ू': 'ū',
| |
| 'ृ': 'ṛ', 'ॄ': 'ṝ', 'े': 'e', 'ै': 'ai', 'ो': 'o', 'ौ': 'au'
| |
| };
| |
| var VOWELS = {
| |
| 'अ': 'a', 'आ': 'ā', 'इ': 'i', 'ई': 'ī', 'उ': 'u', 'ऊ': 'ū',
| |
| 'ऋ': 'ṛ', 'ॠ': 'ṝ', 'ए': 'e', 'ऐ': 'ai', 'ओ': 'o', 'औ': 'au', 'ऽ': "'"
| |
| };
| |
| var MISC = {
| |
| 'ं': 'ṃ', 'ः': 'ḥ', 'ँ': 'm̐', 'ॐ': 'oṃ',
| |
| '०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
| |
| '५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
| |
| };
| |
| var HALANTA = '्';
| |
| var chars = Array.from(text), result = '', i = 0;
| |
| while (i < chars.length) {
| |
| var ch = chars[i], next = chars[i + 1];
| |
| if (CONSONANTS[ch]) {
| |
| var base = CONSONANTS[ch];
| |
| if (next === HALANTA) { result += base; i += 2; }
| |
| else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
| |
| else if (next === 'ं' || next === 'ः') { result += base + 'a' + MISC[next]; i += 2; }
| |
| else { result += base + 'a'; i++; }
| |
| }
| |
| else if (VOWELS[ch]) { result += VOWELS[ch]; i++; }
| |
| else if (DIACRITICS[ch]) { result += DIACRITICS[ch]; i++; }
| |
| else if (MISC[ch]) { result += MISC[ch]; i++; }
| |
| else { result += ch; i++; }
| |
| }
| |
| return result;
| |
| }
| |
|
| |
|
| var SCRIPT_MAP = {
| | var _selRange = null; |
| kn: {
| | var _selText = ''; |
| 'अ': 'ಅ', 'आ': 'ಆ', 'इ': 'ಇ', 'ई': 'ಈ', 'उ': 'ಉ', 'ऊ': 'ಊ', 'ऋ': 'ಋ', 'ॠ': 'ೠ',
| | var _selRect = null; |
| 'ए': 'ಏ', 'ऐ': 'ಐ', 'ओ': 'ಓ', 'औ': 'ಔ', 'ऽ': 'ಽ',
| | var _notes = []; |
| 'क': 'ಕ', 'ख': 'ಖ', 'ग': 'ಗ', 'घ': 'ಘ', 'ङ': 'ಙ',
| | var _bookmarks = []; |
| 'च': 'ಚ', 'छ': 'ಛ', 'ज': 'ಜ', 'झ': 'ಝ', 'ञ': 'ಞ',
| | var _activeTab = 'notes'; |
| 'ट': 'ಟ', 'ठ': 'ಠ', 'ड': 'ಡ', 'ढ': 'ಢ', 'ण': 'ಣ',
| | var _selVersion = 0; |
| 'त': 'ತ', 'थ': 'ಥ', 'द': 'ದ', 'ध': 'ಧ', 'न': 'ನ',
| | var _fabSelVer = -1; |
| 'प': 'ಪ', 'फ': 'ಫ', 'ब': 'ಬ', 'भ': 'ಭ', 'म': 'ಮ',
| | var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; |
| 'य': 'ಯ', 'र': 'ರ', 'ल': 'ಲ', 'ळ': 'ಳ', 'व': 'ವ',
| | var _fabTouched = false; // flag to prevent hideActions when tapping fab |
| 'श': 'ಶ', 'ष': 'ಷ', 'स': 'ಸ', 'ह': 'ಹ',
| |
| 'ा': 'ಾ', 'ि': 'ಿ', 'ी': 'ೀ', 'ु': 'ು', 'ू': 'ೂ',
| |
| 'ृ': 'ೃ', 'ॄ': 'ೄ', 'े': 'ೇ', 'ै': 'ೈ', 'ो': 'ೋ', 'ौ': 'ೌ',
| |
| 'ं': 'ಂ', 'ः': 'ಃ', '्': '್', 'ँ': 'ಁ',
| |
| '०': '೦', '१': '೧', '२': '೨', '३': '೩', '४': '೪',
| |
| '५': '೫', '६': '೬', '७': '೭', '८': '೮', '९': '೯'
| |
| },
| |
| te: {
| |
| 'अ': 'అ', 'आ': 'ఆ', 'इ': 'ఇ', 'ई': 'ఈ', 'उ': 'ఉ', 'ऊ': 'ఊ', 'ऋ': 'ఋ', 'ॠ': 'ౠ',
| |
| 'ए': 'ఏ', 'ऐ': 'ఐ', 'ओ': 'ఓ', 'औ': 'ఔ', 'ऽ': 'ఽ',
| |
| 'क': 'క', 'ख': 'ఖ', 'ग': 'గ', 'घ': 'ఘ', 'ङ': 'ఙ',
| |
| 'च': 'చ', 'छ': 'ఛ', 'ज': 'జ', 'झ': 'ఝ', 'ञ': 'ఞ',
| |
| 'ट': 'ట', 'ठ': 'ఠ', 'ड': 'డ', 'ढ': 'ఢ', 'ण': 'ణ',
| |
| 'त': 'త', 'थ': 'థ', 'द': 'ద', 'ध': 'ధ', 'न': 'న',
| |
| 'प': 'ప', 'फ': 'ఫ', 'ब': 'బ', 'भ': 'భ', 'म': 'మ',
| |
| 'य': 'య', 'र': 'ర', 'ल': 'ల', 'ळ': 'ళ', 'व': 'వ',
| |
| 'श': 'శ', 'ष': 'ష', 'स': 'స', 'ह': 'హ',
| |
| 'ा': 'ా', 'ि': 'ి', 'ी': 'ీ', 'ु': 'ు', 'ू': 'ూ',
| |
| 'ृ': 'ృ', 'ॄ': 'ౄ', 'े': 'ే', 'ै': 'ై', 'ो': 'ో', 'ौ': 'ౌ',
| |
| 'ं': 'ం', 'ः': 'ః', '्': '్', 'ँ': 'ఁ',
| |
| '०': '౦', '१': '౧', '२': '౨', '३': '౩', '४': '౪',
| |
| '५': '౫', '६': '౬', '७': '౭', '८': '౮', '९': '౯'
| |
| }
| |
| };
| |
|
| |
|
| var PRE = [ | | function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); } |
| [/ङ्क/g, 'ंक'], [/ङ्ख/g, 'ंख'], [/ङ्ग/g, 'ंग'], [/ङ्घ/g, 'ंघ'],
| | function esc(s) { |
| [/ञ्च/g, 'ंच'], [/ञ्ज/g, 'ंज'], [/ण्ट/g, 'ंट'], [/ण्ड/g, 'ंड'],
| | return String(s||'').replace(/&/g,'&').replace(/</g,'<') |
| [/न्त/g, 'ंत'], [/न्द/g, 'ंद'], [/म्ब/g, 'ंब'], [/म्भ/g, 'ंभ']
| | .replace(/>/g,'>').replace(/"/g,'"'); |
| ]; | | } |
| | function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); } |
| | function fmtTs(ts) { |
| | try { |
| | var d = new Date(ts); |
| | return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'}) |
| | + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false}); |
| | } catch(e){ return ts; } |
| | } |
| | function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); } |
| | function isMobile() { return _mobile; } |
|
| |
|
| function transliterateText(text, script) {
| | // The server caps a note at 8192 *bytes* (MAX_TEXT_BYTES), and Devanagari |
| if (script === 'en') return devanagariToIAST(text);
| | // runs three bytes to the character, so the real limit is about 2,700 |
| if (script === 'ta') return devanagariToTamil(text);
| | // characters — reachable for the kind of thing people write here. Caught |
| var map = SCRIPT_MAP[script];
| | // at the composer, because the alternative is a note that looks saved, |
| if (!map) return text;
| | // sits in the panel all session, and is simply absent the next time the |
| var t = text;
| | // page is opened. |
| PRE.forEach(function (p) { t = t.replace(p[0], p[1]); });
| | var MAX_NOTE_BYTES = 8192; |
| return Array.from(t).map(function (ch) {
| | function byteLength(s) { |
| return map[ch] !== undefined ? map[ch] : ch;
| | try { return new TextEncoder().encode(s).length; } |
| }).join('');
| | catch (e) { return unescape(encodeURIComponent(s)).length; } |
| }
| | } |
|
| |
|
| var translatableSpans = [];
| | /* ══════════════════════ Store ══════════════════════ |
| | * Notes and bookmarks live in the wiki database, in gr_annotations, via |
| | * the GranthaAnnotations extension — which is what lets the same notes |
| | * show up in the mobile app. They used to live only in localStorage, so |
| | * they were stranded in one browser. |
| | * |
| | * localStorage does not go away; it changes job. It is now |
| | * · a cache, so a reload paints the panel instantly instead of waiting |
| | * on the network, and a page read offline still shows notes |
| | * · an outbox mirror, so a write made while offline survives a reload |
| | * The database is the record. The browser is a convenience in front of it. |
| | * |
| | * Anonymous readers have no database row to own, and the API refuses them |
| | * outright, so for them localStorage stays the store exactly as before. |
| | * Every write is optimistic: memory and cache update first, the panel |
| | * re-renders immediately, and the request follows. |
| | */ |
| | var Store = ( function () { |
|
| |
|
| function tagTextNodes() { | | var loggedIn = !!currentUser; |
| var content = document.querySelector('.mw-parser-output');
| | // Rejections the server will never change its mind about. Anything not |
| if (content) {
| | // listed here is treated as temporary and retried. |
| var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
| | var TERMINAL = { badid: 1, notowner: 1, texttoolong: 1, missingparam: 1 }; |
| var nodes = [];
| | var _api = null; |
| while (walker.nextNode()) nodes.push(walker.currentNode);
| | var flushing = null; // the in-flight flush's promise, or null |
| nodes.forEach(function (node) {
| | var outbox = []; // authoritative in memory; mirrored to LS |
| var p = node.parentNode;
| |
| if (!p) return;
| |
| if (p.hasAttribute && p.hasAttribute('data-deva')) return;
| |
| if (p.closest) {
| |
| if (p.closest('.gr-controls')) return;
| |
| if (p.closest('.mw-editsection')) return;
| |
| // Note markers are [1], [2] … — machinery, not text, and
| |
| // the numbers would be transliterated along with the page.
| |
| if (p.closest('.gr-note-marker')) return;
| |
| }
| |
| var orig = node.textContent;
| |
| if (!orig.trim()) return;
| |
| var span = document.createElement('span');
| |
| span.setAttribute('data-deva', orig);
| |
| span.textContent = orig;
| |
| p.replaceChild(span, node);
| |
| translatableSpans.push(span);
| |
| });
| |
| }
| |
| document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
| |
| }
| |
|
| |
|
| // A contents entry is "1.2 शीर्षकम्": the number lives in its own | | // mw.Api only exists once mediawiki.api has loaded, and asking for it |
| // .vector-toc-numb span, which Common.css hides. Tagging the whole | | // sooner throws. Everything that talks to the server waits on this, so a |
| // .vector-toc-text meant transliteration replaced its contents with | | // load failure degrades to cache-and-queue instead of throwing. |
| // one flat string — number included — so switching to Tamil or any
| | function ready() { |
| // other script made the numbering appear. Only the label is tagged.
| | if ( !window.mw || !mw.loader ) return $.Deferred().reject().promise(); |
| function tagTocEntry(textEl) { | | return mw.loader.using( 'mediawiki.api' ).then( function () { |
| if (!textEl || textEl.hasAttribute('data-deva')) return;
| | if ( !_api ) _api = new mw.Api(); |
| var label = textEl.querySelector('[data-deva]');
| | return _api; |
| if (!label) {
| | } ); |
| label = document.createElement('span');
| |
| label.className = 'gr-toc-label';
| |
| Array.prototype.slice.call(textEl.childNodes).forEach(function (n) {
| |
| if (n.nodeType === 1 && n.classList && n.classList.contains('vector-toc-numb')) return;
| |
| label.appendChild(n); // moves it, order preserved
| |
| });
| |
| textEl.appendChild(label);
| |
| }
| |
| var orig = label.textContent;
| |
| if (!orig.trim()) return; | |
| if (!label.hasAttribute('data-deva')) label.setAttribute('data-deva', orig);
| |
| if (translatableSpans.indexOf(label) === -1) translatableSpans.push(label);
| |
| } | | } |
|
| |
|
| // Which script is on screen, published to CSS. Common.css §2b uses it to
| | function ls( k, v ) { |
| // swap the reading area onto --font-indic while a transliteration is
| | try { |
| // showing: most rules here name Adishila directly, which has no Tamil,
| | if ( v === undefined ) return localStorage.getItem( k ); |
| // Kannada or Telugu letters, so without this the switched text fell back
| | localStorage.setItem( k, v ); |
| // to whatever serif the visitor's machine happened to have — the reason
| | return true; |
| // it looked uneven from one line to the next.
| | } catch ( e ) { return null; } |
| function markScriptOnRoot(script) { | |
| try {
| |
| var el = document.documentElement;
| |
| if (!el) return;
| |
| if (!script || script === 'deva') el.removeAttribute('data-gr-script');
| |
| else el.setAttribute('data-gr-script', script);
| |
| } catch (e) { } | |
| }
| |
| | |
| function applyScript(script) {
| |
| currentScript = script;
| |
| markScriptOnRoot(script);
| |
| translatableSpans.forEach(function (span) {
| |
| if (!span.parentNode) return;
| |
| var orig = span.getAttribute('data-deva');
| |
| if (!orig) return;
| |
| span.textContent = (script === 'deva')
| |
| ? orig
| |
| : transliterateText(orig, script);
| |
| });
| |
| }
| |
| | |
| function _isNoTocPage() {
| |
| var pn = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
| |
| return pn === 'Main_Page' || /^[A-Za-z0-9_]+:About$/.test(pn);
| |
| } | | } |
| | | function readJson( k, fallback ) { |
| function renameTocTitle() { | | try { return JSON.parse( ls( k ) || '' ) || fallback; } catch ( e ) { return fallback; } |
| if (_isNoTocPage()) return;
| |
| var toc = document.querySelector('.vector-toc');
| |
| if (!toc) return;
| |
| var titleEl = toc.querySelector('.vector-toc-title') || toc.querySelector('.vector-pinnable-header-label');
| |
| if (!titleEl) return;
| |
| var LABEL = 'विषयसूची';
| |
| var span = titleEl.querySelector('.gr-toc-title');
| |
| if (!span) {
| |
| titleEl.innerHTML = '';
| |
| span = document.createElement('span');
| |
| span.className = 'gr-toc-title';
| |
| span.setAttribute('data-deva', LABEL);
| |
| titleEl.appendChild(span);
| |
| translatableSpans.push(span);
| |
| }
| |
| span.textContent = currentScript === 'deva' ? LABEL : transliterateText(LABEL, currentScript);
| |
| } | | } |
| | function whoami() { return currentUser || null; } |
|
| |
|
| function removeTocBeginning() { | | /* ── Outbox ────────────────────────────────────────────────────── |
| if (_isNoTocPage()) return;
| | * Held in memory and mirrored to localStorage, not held *in* it. A |
| var toc = document.querySelector('.vector-toc');
| | * browser that refuses to store — private mode, quota, policy — used to |
| if (!toc) return;
| | * mean the write was never sent at all, because the send re-read the |
| var el = toc.querySelector('#vector-toc-beginning');
| | * queue it had just failed to write. Now storage only decides whether an |
| if (!el) {
| | * unsent write survives a reload. |
| var items = toc.querySelectorAll('.vector-toc-list-item');
| | * |
| for (var i = 0; i < items.length; i++) {
| | * Keyed per user AND per page: the cache keys have always been per page, |
| var a = items[i].querySelector('a');
| | * so a queue scoped only by user cannot describe them. */ |
| if (a) {
| | function qKey() { return Q_LS_KEY + '_' + ( currentUser || 'anon' ) + '_' + pageTitle; } |
| var href = a.getAttribute('href') || '';
| | function mirror() { ls( qKey(), JSON.stringify( outbox ) ); } |
| if (href.indexOf('#') === -1) { el = items[i]; break; }
| |
| }
| |
| }
| |
| }
| |
| if (el && el.parentNode) el.parentNode.removeChild(el);
| |
| }
| |
| | |
| // The commentary list at the top of a moola page has its own heading
| |
| // (टीकाः). It isn't part of the text, so it doesn't belong in the
| |
| // contents. Recognised by what the section holds — the .gr-doc-teekas
| |
| // list, or a section whose only links are to /Vyakhya/ pages — with
| |
| // the heading's own wording as a last resort, since the list markup
| |
| // differs between pages and the commentary picker rewrites it.
| |
| var TEEKA_HEADINGS = /^(टीकाः?|व्याख्याः?|टीकाग्रन्थाः|Teekas?|Commentaries)$/i;
| |
| function isTeekaSection(headingEl) { | |
| var wrap = (headingEl.closest && headingEl.closest('.mw-heading')) || headingEl;
| |
| var n = wrap.nextElementSibling, links = [], vyakhya = 0, steps = 0;
| |
| while (n && steps < 6) {
| |
| if (n.classList && n.classList.contains('gr-doc-teekas')) return true;
| |
| if (n.querySelector && n.querySelector('.gr-doc-teekas')) return true;
| |
| if ((n.tagName && /^H[1-6]$/.test(n.tagName)) || (n.classList && n.classList.contains('mw-heading'))) break;
| |
| if (n.querySelectorAll) {
| |
| Array.prototype.forEach.call(n.querySelectorAll('a[href]'), function (a) {
| |
| var href = a.getAttribute('href') || '';
| |
| if (/^#/.test(href)) return;
| |
| links.push(href);
| |
| if (/\/Vyakhya\//i.test(decodeURIComponent(href))) vyakhya++;
| |
| });
| |
| }
| |
| n = n.nextElementSibling; steps++;
| |
| }
| |
| if (links.length && vyakhya === links.length) return true;
| |
| return TEEKA_HEADINGS.test((headingEl.textContent || '').trim());
| |
| }
| |
| function removeTocTeekaEntry() { | |
| if (_isNoTocPage()) return;
| |
| var toc = document.querySelector('.vector-toc');
| |
| if (!toc) return;
| |
| Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-link[href^="#"]'), function (a) {
| |
| var id = a.getAttribute('href').slice(1);
| |
| try { id = decodeURIComponent(id); } catch (e) { }
| |
| var h = id && document.getElementById(id);
| |
| if (!h || !isTeekaSection(h)) return;
| |
| var li = a.closest('.vector-toc-list-item');
| |
| if (li && li.parentNode) li.parentNode.removeChild(li);
| |
| });
| |
| }
| |
|
| |
|
| // Section numbers ("2.1.1") are removed from the contents outright. | | function enqueue( op ) { |
| // Hiding them with CSS was not enough: the script switcher replaces an
| | // One pending operation per annotation. A note edited twice before the |
| // entry's contents with one flat string, which turned the hidden number
| | // network returns should send its final state once, not both states in |
| // into ordinary text — so it reappeared in Tamil, Kannada and the rest.
| | // order, and a delete supersedes any save still waiting behind it. |
| // They are also wrong here, since this contents list leaves entries out.
| | outbox = outbox.filter( function ( x ) { return x.id !== op.id; } ); |
| var NUMBERED = /^\s*\d+(?:\.\d+)*[.)]?\s+([\s\S]*\S)\s*$/;
| | outbox.push( op ); |
| function stripTocNumbers() {
| | mirror(); |
| var toc = document.querySelector('.vector-toc');
| |
| if (!toc) return;
| |
| Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-numb'), function (n) {
| |
| if (n.parentNode) n.parentNode.removeChild(n);
| |
| });
| |
| // Entries another script already flattened: take the number off the
| |
| // text, and off the Devanagari original the switcher re-reads.
| |
| Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-text, .vector-toc-text [data-deva]'), function (el) {
| |
| var d = el.getAttribute && el.getAttribute('data-deva');
| |
| if (d) { var md = NUMBERED.exec(d); if (md) el.setAttribute('data-deva', md[1]); }
| |
| if (!el.children.length) {
| |
| var mt = NUMBERED.exec(el.textContent);
| |
| if (mt) el.textContent = mt[1];
| |
| }
| |
| });
| |
| } | | } |
| // The switcher can rewrite the contents at any time, so watch for it. | | // Removes the exact operation that was sent, by identity. Matching on |
| var _numbObserver = null;
| | // id and op instead would let a completing send delete a *newer* |
| function watchTocNumbers() { | | // operation for the same annotation that replaced it mid-flight. |
| var toc = document.querySelector('.vector-toc');
| | function dequeue( op ) { |
| if (!toc || _numbObserver || !window.MutationObserver) return;
| | var i = outbox.indexOf( op ); |
| var pending = false;
| | if ( i === -1 ) return; |
| _numbObserver = new MutationObserver(function () {
| | outbox.splice( i, 1 ); |
| if (pending) return;
| | mirror(); |
| pending = true;
| |
| setTimeout(function () { pending = false; stripTocNumbers(); }, 50);
| |
| });
| |
| _numbObserver.observe(toc, { childList: true, subtree: true, characterData: true });
| |
| } | | } |
|
| |
|
| function expandTocSections() { | | function send( op ) { |
| if (_isNoTocPage()) return;
| | var params = { action: 'granthaannotationedit', op: op.op, id: op.id, format: 'json' }; |
| var toc = document.querySelector('.vector-toc'); | | if ( op.op === 'save' ) { |
| if (!toc) return; | | params.page = op.page; |
| toc.querySelectorAll('.vector-toc-list-item-collapsed').forEach(function (li) {
| | params.type = op.type; |
| li.classList.remove('vector-toc-list-item-collapsed');
| | params.quote = op.quote || ''; |
| });
| | params.text = op.text || ''; |
| | if ( op.ts ) params.ts = op.ts; |
| | } |
| | return ready().then( function ( a ) { return a.postWithToken( 'csrf', params ); } ); |
| } | | } |
|
| |
|
| function injectTocDocNav() { | | /* Drain the outbox oldest-first, stopping at the first failure so |
| if (_isNoTocPage()) return;
| | * ordering is preserved and a server that is down is not hammered. */ |
| var toc = document.querySelector('.vector-toc');
| | function flush() { |
| if (!toc) return;
| | if ( !loggedIn ) return $.Deferred().resolve().promise(); |
| if (document.getElementById('gr-toc-doc-nav')) return;
| | // Return the flush already running rather than a fresh resolved |
| var artPath = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
| | // promise: load() waits on this before reading, and handing it an |
| var pageTitle = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
| | // instantly-resolved promise would let the read race the writes. |
| var teekaPage = document.querySelector('.gr-teeka-page');
| | if ( flushing ) return flushing; |
| var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || '') : '';
| | if ( !outbox.length ) return $.Deferred().resolve().promise(); |
| if (!primarySlug) { primarySlug = pageTitle.split('/')[0]; }
| |
| if (!primarySlug) return;
| |
| function wikiUrl(slug) {
| |
| if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug);
| |
| return artPath.replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/'));
| |
| }
| |
| var docTitleEl = document.querySelector('.gr-doc-title');
| |
| var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1';
| |
| var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1';
| |
| var showMoolaPage = !teekaPage && hasMoolaPage;
| |
| var showMoolaBack = !!teekaPage;
| |
| var showUllekha = hasUllekhaPage || !!teekaPage;
| |
| if (!showMoolaPage && !showMoolaBack && !showUllekha) return;
| |
| var nav = document.createElement('div');
| |
| nav.id = 'gr-toc-doc-nav';
| |
| nav.setAttribute('class', 'toc-main-links');
| |
| function makeBtn(href, label) {
| |
| var a = document.createElement('a');
| |
| a.href = href;
| |
| a.setAttribute('class', 'toc-main-link-item');
| |
| var lspan = document.createElement('span');
| |
| lspan.setAttribute('data-deva', label);
| |
| lspan.textContent = (currentScript && currentScript !== 'deva') ? transliterateText(label, currentScript) : label;
| |
| translatableSpans.push(lspan);
| |
| a.appendChild(lspan);
| |
| return a;
| |
| }
| |
| if (showMoolaPage) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
| |
| if (showMoolaBack) nav.appendChild(makeBtn(wikiUrl(primarySlug), 'मूल'));
| |
| if (showUllekha) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
| |
| // Under the document's own title in the page, the same as on a
| |
| // phone — not tucked into the contents panel, where these were
| |
| // easy to miss. The contents panel is only the fallback.
| |
| var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
| |
| if (docTitle && docTitle.parentNode) { docTitle.parentNode.insertBefore(nav, docTitle.nextSibling); return; }
| |
| var h1 = document.getElementById('firstHeading') || document.querySelector('.mw-first-heading');
| |
| if (h1 && h1.parentNode) { h1.parentNode.insertBefore(nav, h1.nextSibling); return; }
| |
| var tocContents = toc.querySelector('.vector-toc-contents');
| |
| if (tocContents) toc.insertBefore(nav, tocContents);
| |
| else toc.appendChild(nav);
| |
| }
| |
|
| |
|
| var _headingObserver = null;
| | var d = $.Deferred(); |
| var _tocActiveId = null;
| | flushing = d.promise(); |
| | | ( function step() { |
| // NEW: moved out of attachHeadingObserver's own local scope so they
| | if ( !outbox.length ) { flushing = null; d.resolve(); return; } |
| // can be reused both by the scroll observer AND by a direct trigger
| | var op = outbox[ 0 ]; |
| // for search-result clicks — was previously only reachable from
| | send( op ).done( function () { |
| // inside attachHeadingObserver's own IntersectionObserver callback.
| | dequeue( op ); |
| // _tocActiveId (was _activeId, local to attachHeadingObserver) is
| | // Guard against a queue that cannot shrink, which would loop |
| // module-level now for the same reason — both callers need to share
| | // re-POSTing the same row for ever. Checked by identity, not by |
| // the same "what's currently active" state, not track it separately.
| | // length: a note written while this request was in flight makes |
| function getTocLink(id) {
| | // the queue longer, and comparing lengths stopped the drain with |
| var toc = document.querySelector('.vector-toc'); | | // that newer write still unsent. |
| if (!toc) return null;
| | if ( outbox.indexOf( op ) !== -1 ) { flushing = null; d.resolve(); return; } |
| return toc.querySelector('a[href="#' + CSS.escape(id) + '"]'); | | step(); |
| }
| | } ).fail( function ( code ) { |
| function getTocLi(id) {
| | // Verdicts the server will never change its mind about. Left at |
| var a = getTocLink(id);
| | // the head of the queue they block every later write on this page |
| return a ? a.closest('.vector-toc-list-item') : null;
| | // for ever, so they are dropped and the drain carries on. |
| }
| | if ( TERMINAL[ code ] ) { |
| // Highlighting is class-only (styled in Common.css §6):
| | dequeue( op ); |
| // .gr-toc-current — the one entry being read, on the <a> itself
| | step(); |
| // .gr-toc-in-path — its parent sections, marked on the <li>
| | return; |
| // Previously this set inline colours on the active item AND on every
| | } |
| // element inside it, so a section with sub-sections lit up whole.
| | // Anything else — offline, 5xx, session expired — is temporary. |
| function clearActiveToc() {
| | // Stop here so ordering is kept and a struggling server is not |
| var toc = document.querySelector('.vector-toc');
| | // hammered; the next write, `online`, or page load retries. |
| if (!toc) return;
| | flushing = null; |
| toc.querySelectorAll('.gr-toc-current').forEach(function (el) { el.classList.remove('gr-toc-current'); });
| | d.resolve(); |
| toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
| | } ); |
| }
| | }() ); |
| function ownLink(li) {
| | return flushing; |
| return li.querySelector(':scope > .vector-toc-link') || li.querySelector('.vector-toc-link'); | |
| }
| |
| function isShown(el) {
| |
| return !!(el && (el.offsetParent || (el.getClientRects && el.getClientRects().length)));
| |
| }
| |
| // Open a collapsed section so a given entry can be seen. Vector marks a
| |
| // top-level item open with vector-toc-list-item-expanded plus
| |
| // aria-expanded on its toggle, so this matches what its own button does.
| |
| function expandTocFor(li) {
| |
| var top = li.closest ? li.closest('.vector-toc-level-1') : null;
| |
| if (!top) return;
| |
| if (!top.classList.contains('vector-toc-list-item-expanded')) {
| |
| top.classList.add('vector-toc-list-item-expanded'); | |
| var tg = top.querySelector('.vector-toc-toggle'); | |
| if (tg) tg.setAttribute('aria-expanded', 'true'); | |
| }
| |
| var anc = li.parentNode;
| |
| while (anc && anc.classList) {
| |
| anc.classList.remove('vector-toc-list-item-collapsed');
| |
| if (anc.classList.contains('vector-toc')) break;
| |
| anc = anc.parentNode;
| |
| }
| |
| }
| |
| | |
| // expand=true when the reader picked the entry (TOC click, search
| |
| // result, #hash): open its section so the exact entry is shown.
| |
| // expand=false while scrolling: never open or close anything — if the
| |
| // entry is hidden inside a collapsed section, the section itself is
| |
| // highlighted instead, so the reader still sees where they are.
| |
| function setActiveToc(id, expand) {
| |
| var toc = document.querySelector('.vector-toc');
| |
| if (!toc) return;
| |
| var shownNow = toc.querySelector('.gr-toc-current');
| |
| if (_tocActiveId === id && !expand && shownNow && isShown(shownNow)) return;
| |
| _tocActiveId = id;
| |
| clearActiveToc();
| |
| if (!id) return;
| |
| var li = getTocLi(id);
| |
| if (!li) return;
| |
| if (expand) expandTocFor(li);
| |
| | |
| var lnk = ownLink(li);
| |
| var hostLi = li;
| |
| if (lnk && !isShown(lnk)) { // hidden in a collapsed section
| |
| var p = li.parentNode;
| |
| while (p && p !== toc) {
| |
| if (p.classList && p.classList.contains('vector-toc-list-item')) {
| |
| var pl = ownLink(p);
| |
| if (pl && isShown(pl)) { lnk = pl; hostLi = p; break; }
| |
| }
| |
| p = p.parentNode;
| |
| }
| |
| } | |
| if (lnk) lnk.classList.add('gr-toc-current');
| |
| // Sections above it get the quieter "you are in here" treatment.
| |
| var anc = hostLi.parentNode;
| |
| while (anc && anc !== toc) {
| |
| if (anc.classList && anc.classList.contains('vector-toc-list-item')) anc.classList.add('gr-toc-in-path');
| |
| anc = anc.parentNode;
| |
| }
| |
| keepTocEntryVisible(lnk || hostLi);
| |
| } | | } |
|
| |
|
| // Scroll the TOC panel so the active entry is visible. This used to
| | function write( op ) { |
| // measure the whole <li>, which for a section with sub-sections is as
| | if ( !loggedIn ) return; // nothing to send, and nothing to accumulate |
| // tall as the entire branch — so "centre it" landed somewhere inside
| | enqueue( op ); |
| // the NEXT section. It now measures the link itself, and finds the
| | flush(); |
| // element that actually scrolls rather than assuming one.
| |
| function keepTocEntryVisible(el) { | |
| if (!el) return;
| |
| var sc = el.parentNode;
| |
| while (sc && sc !== document.body && sc.nodeType === 1) {
| |
| var oy = window.getComputedStyle(sc).overflowY;
| |
| if ((oy === 'auto' || oy === 'scroll') && sc.scrollHeight > sc.clientHeight + 4) break;
| |
| sc = sc.parentNode;
| |
| }
| |
| if (!sc || sc === document.body || sc.nodeType !== 1) return; // nothing scrolls: leave the page alone
| |
| var er = el.getBoundingClientRect(), cr = sc.getBoundingClientRect();
| |
| if (er.top >= cr.top + 8 && er.bottom <= cr.bottom - 8) return; // already in view
| |
| sc.scrollTop += (er.top - cr.top) - Math.max((sc.clientHeight - er.height) / 2, 0);
| |
| } | | } |
|
| |
|
| // NEW: given any element in the article content, finds the nearest | | /* ── public ────────────────────────────────────────────────────── */ |
| // heading (with an id) that precedes it in document order, using
| |
| // compareDocumentPosition — robust regardless of nesting depth.
| |
| // Verified against a realistic multi-section fragment, including
| |
| // the "before any heading" and "right after a chapter heading, no
| |
| // sub-heading yet" edge cases, before wiring this in.
| |
| function findNearestHeadingId(target) {
| |
| var content = document.querySelector('.mw-parser-output');
| |
| if (!content) return null;
| |
| var headings = content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]');
| |
| var nearest = null;
| |
| for (var i = 0; i < headings.length; i++) {
| |
| var h = headings[i];
| |
| var pos = h.compareDocumentPosition(target);
| |
| if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
| |
| nearest = h.id;
| |
| } else {
| |
| break;
| |
| }
| |
| }
| |
| return nearest;
| |
| }
| |
| | |
| // NEW: exposed so the search-highlight IIFE (below) can directly
| |
| // trigger the TOC to expand and highlight the right section right
| |
| // after a search-result scroll lands — rather than relying solely on
| |
| // the IntersectionObserver to catch up on its own timing.
| |
| window.grHighlightTocForElement = function (el) {
| |
| if (!el) return;
| |
| var id = findNearestHeadingId(el);
| |
| if (id) pinActive(id);
| |
| };
| |
|
| |
|
| // ── Which section am I reading? ──────────────────────────────── | | /* What has been written locally since the last answer from the server. |
| // Was an IntersectionObserver with a shrunken root band. Two faults
| | * The outbox alone is not enough to reconcile with: a note written while |
| // came out of that, both reported as "the highlight is wrong":
| | * the initial read was in flight may well have finished its own POST |
| // • right after jumping to a heading, that heading sits ABOVE the
| | * first, and would then be gone from the outbox by the time the older |
| // band, so nothing was "visible" and the fallback picked the
| | * answer arrives — which would take it off the screen and out of the |
| // PREVIOUS heading — clicking a sub-section highlighted its parent;
| | * cache. These two maps outlive the outbox entry and are cleared only |
| // • once every heading was outside the band, no intersection ever
| | * when a read has actually been folded in. */ |
| // changed again, so the observer stopped firing and the highlight
| | var localSaves = {}, localDeletes = {}; |
| // froze wherever it was.
| |
| // This measures instead: the active section is the last heading above
| |
| // a reading line just under the sticky toolbar. It updates on every
| |
| // scroll frame and can't go stale.
| |
| var _tocHeadings = []; | |
| var _tocTops = [];
| |
| var _tocMeasuredHeight = 0;
| |
| // A clicked / jumped-to heading is "pinned": it stays the active entry
| |
| // until the reader actually scrolls away from it. Without this, the
| |
| // anchor jump leaves the heading below the reading line (the sticky
| |
| // bar and scroll-margin push it down the viewport), so the measurement
| |
| // picked the heading ABOVE it and the highlight jumped back one.
| |
| var _pin = null; // {id, y, until}
| |
|
| |
|
| function measureHeadings() { | | function save( type, rec ) { |
| var y = window.pageYOffset || document.documentElement.scrollTop || 0;
| | localSaves[ rec.id ] = { type: type, rec: rec }; |
| _tocTops = _tocHeadings.map(function (h) { return h.getBoundingClientRect().top + y; });
| | delete localDeletes[ rec.id ]; |
| _tocMeasuredHeight = document.documentElement.scrollHeight;
| | write( { |
| }
| | op: 'save', id: rec.id, page: pageTitle, type: type, |
| function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
| | quote: rec.quote || '', text: type === 'bookmark' ? rec.name : rec.text, |
| // The line down the page that decides "which section am I reading":
| | ts: rec.ts |
| // just below whatever is stuck to the top, plus a quarter of the
| | } ); |
| // screen, so a heading sitting near the top of the viewport counts as
| |
| // the one being read rather than the one before it.
| |
| function readingLine() {
| |
| var bar = document.getElementById('gr-static-bar'); | |
| var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0;
| |
| var vh = window.innerHeight || document.documentElement.clientHeight || 800; | |
| return scrollY() + barBottom + Math.max(Math.round(vh * 0.25), 80);
| |
| } | | } |
| // Scroll something into a comfortable reading position and KEEP it
| | function remove( id ) { |
| // there. A plain scrollIntoView lands short or long whenever the page
| | localDeletes[ id ] = true; |
| // reflows just after the jump — a web font finishing, a commentary
| | delete localSaves[ id ]; |
| // being interleaved, the match itself being wrapped in a highlight —
| | write( { op: 'delete', id: id } ); |
| // which is why search results came to rest slightly above or below
| |
| // the text. This aims just under whatever is stuck to the top, then
| |
| // re-checks until the page has settled, and gives up the moment the
| |
| // reader scrolls, so it never fights them.
| |
| function grDesiredTop() { | |
| var bar = document.getElementById('gr-static-bar');
| |
| var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0;
| |
| var vh = window.innerHeight || document.documentElement.clientHeight || 800;
| |
| return barBottom + Math.max(Math.round(vh * 0.22), 72);
| |
| } | | } |
| window.grScrollToElement = function (el) {
| |
| if (!el || !el.getBoundingClientRect) return;
| |
| var cancelled = false, ticks = 0, lastY = null, iv = null;
| |
| function onUser() { cancelled = true; }
| |
| function cleanup() {
| |
| if (iv) clearInterval(iv);
| |
| window.removeEventListener('wheel', onUser);
| |
| window.removeEventListener('touchstart', onUser);
| |
| window.removeEventListener('keydown', onUser);
| |
| }
| |
| window.addEventListener('wheel', onUser, { passive: true });
| |
| window.addEventListener('touchstart', onUser, { passive: true });
| |
| window.addEventListener('keydown', onUser);
| |
| function delta() { return el.getBoundingClientRect().top - grDesiredTop(); }
| |
| try { window.scrollBy({ top: delta(), left: 0, behavior: 'smooth' }); }
| |
| catch (e) { window.scrollBy(0, delta()); }
| |
| iv = setInterval(function () {
| |
| ticks++;
| |
| if (cancelled || ticks > 12 || !document.body.contains(el)) { cleanup(); return; }
| |
| var y = scrollY();
| |
| if (lastY !== null && Math.abs(y - lastY) < 1) { // the scroll has come to rest
| |
| var d = delta();
| |
| if (Math.abs(d) > 6) window.scrollBy(0, d); // reflow moved it: correct at once
| |
| else { cleanup(); return; }
| |
| }
| |
| lastY = y;
| |
| }, 200);
| |
| };
| |
|
| |
|
| // Called when the reader picks a heading (TOC click, search result, | | /* Cache what is on screen, so the next load of this page paints from it |
| // or a #hash in the URL) — that heading wins until they scroll on.
| | * before the network answers. |
| function pinActive(id) {
| | * |
| if (!id) return;
| | * The cache records whose it is, for both signed-in and anonymous |
| _pin = { id: id, y: scrollY(), until: Date.now() + 900 };
| | * readers. The keys are per page, not per user, and always have been, so |
| setActiveToc(id, true); // open its section if collapsed
| | * without an owner two people sharing a machine would see each other's |
| scheduleRecheck(950);
| | * notes — and worse, the next person to sign in would have the previous |
| }
| | * one's notes uploaded into their account by migrate(). Recording null |
| var _recheck = null;
| | * for "nobody was signed in" is what makes those two cases tellable |
| function scheduleRecheck(ms) { | | * apart from genuinely pre-upgrade data. */ |
| if (_recheck) return;
| | function cache() { |
| _recheck = setTimeout(function () { _recheck = null; updateActiveFromScroll(); }, ms);
| | ls( NT_LS_KEY, JSON.stringify( { u: whoami(), v: _notes } ) ); |
| }
| | ls( BM_LS_KEY, JSON.stringify( { u: whoami(), v: _bookmarks } ) ); |
| function updateActiveFromScroll() {
| |
| if (!_tocHeadings.length) return;
| |
| if (_pin) {
| |
| var y = scrollY();
| |
| if (Date.now() < _pin.until) { // still settling after the jump
| |
| _pin.y = y;
| |
| setActiveToc(_pin.id, true);
| |
| scheduleRecheck(_pin.until - Date.now() + 30);
| |
| return;
| |
| }
| |
| if (Math.abs(y - _pin.y) < 60) { // reader hasn't moved off it
| |
| setActiveToc(_pin.id);
| |
| return;
| |
| }
| |
| _pin = null; // they've scrolled on
| |
| }
| |
| // Interleaving a commentary or changing the font size moves things;
| |
| // re-measure when the page's height has changed.
| |
| if (document.documentElement.scrollHeight !== _tocMeasuredHeight) measureHeadings();
| |
| var line = readingLine(), lo = 0, hi = _tocTops.length - 1, idx = 0;
| |
| while (lo <= hi) { // last heading at or above the line
| |
| var mid = (lo + hi) >> 1;
| |
| if (_tocTops[mid] <= line + 1) { idx = mid; lo = mid + 1; } else { hi = mid - 1; }
| |
| }
| |
| setActiveToc(_tocHeadings[idx].id);
| |
| } | | } |
|
| |
|
| function attachHeadingObserver() { | | function cached( key ) { |
| if (_isNoTocPage()) return;
| | var raw = readJson( key, null ); |
| if (_headingObserver) return;
| | if ( !raw ) return []; |
| var content = document.querySelector('.mw-parser-output');
| | // A bare array predates this version; only migrate() may claim it. |
| if (!content) return;
| | if ( Array.isArray( raw ) ) return []; |
| if (!document.querySelector('.vector-toc')) return;
| | return ( raw.u === whoami() && Array.isArray( raw.v ) ) ? raw.v : []; |
| // Only headings the contents lists — a heading with no entry (the
| |
| // commentary list, say) would otherwise leave nothing highlighted.
| |
| _tocHeadings = Array.prototype.slice.call(content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]'))
| |
| .filter(function (h) { return !!getTocLink(h.id); });
| |
| if (!_tocHeadings.length) return;
| |
| measureHeadings();
| |
| // Time-based throttle rather than a requestAnimationFrame latch: if
| |
| // one frame callback is ever skipped (background tab, a jump during
| |
| // load) a latch stays stuck and the highlight stops updating for
| |
| // good — which is one of the ways this went stale before.
| |
| var lastRun = 0, trailing = null;
| |
| function onScroll() {
| |
| var now = Date.now();
| |
| if (now - lastRun >= 80) { lastRun = now; updateActiveFromScroll(); }
| |
| else if (!trailing) {
| |
| trailing = setTimeout(function () { trailing = null; lastRun = Date.now(); updateActiveFromScroll(); }, 80);
| |
| }
| |
| }
| |
| window.addEventListener('scroll', onScroll, { passive: true });
| |
| window.addEventListener('resize', function () { measureHeadings(); onScroll(); }, { passive: true });
| |
| if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
| |
| document.fonts.ready.then(function () { measureHeadings(); onScroll(); });
| |
| }
| |
| _headingObserver = { onScroll: onScroll }; // marks "already wired"
| |
| var hash = '';
| |
| try { hash = decodeURIComponent((location.hash || '').slice(1)); } catch (e) { hash = (location.hash || '').slice(1); }
| |
| if (hash && document.getElementById(hash)) pinActive(hash);
| |
| else updateActiveFromScroll();
| |
| } | | } |
|
| |
|
| // Clicking an entry highlights it straight away, rather than waiting | | function fromCache() { |
| // for the scroll observer to catch up after the jump.
| | _notes = cached( NT_LS_KEY ); |
| function attachTocClickHandler(toc) {
| | _bookmarks = cached( BM_LS_KEY ); |
| if (toc.getAttribute('data-gr-click-wired')) return;
| |
| toc.setAttribute('data-gr-click-wired', '1');
| |
| toc.addEventListener('click', function (e) {
| |
| var a = e.target.closest ? e.target.closest('.vector-toc-link') : null;
| |
| if (!a) return;
| |
| var href = a.getAttribute('href') || '';
| |
| if (href.charAt(0) !== '#') return;
| |
| var id;
| |
| try { id = decodeURIComponent(href.slice(1)); } catch (err) { id = href.slice(1); }
| |
| if (id) pinActive(id);
| |
| });
| |
| } | | } |
|
| |
|
| function setupToc() { | | /* ── Claiming ──────────────────────────────────────────────────── |
| if (_isNoTocPage()) return;
| | * Hands over whatever this browser holds with no owner: the bare array |
| var toc = document.querySelector('.vector-toc');
| | * the old gadget wrote, and the anonymous cache this one writes. Both |
| if (!toc) return;
| | * mean "notes taken on this browser with nobody signed in", which is |
| removeTocBeginning();
| | * what a reader who browsed logged out and then registered has. |
| removeTocTeekaEntry();
| | * |
| stripTocNumbers();
| | * There is no "already migrated" flag, deliberately. A flag has to be |
| watchTocNumbers();
| | * scoped, and every scoping is wrong somewhere: per user alone marked |
| renameTocTitle();
| | * every page but the first as done; per user and page was set on the |
| expandTocSections();
| | * first visit even when there was nothing to hand over, so notes taken |
| injectTocDocNav();
| | * anonymously *afterwards* could never be claimed and were overwritten |
| attachTocClickHandler(toc);
| | * on the next sign-in. |
| attachHeadingObserver();
| | * |
| | * What actually marks the job done is cache(), which replaces the |
| | * ownerless value under the same key with an owned one. After a claim |
| | * there is nothing left to claim, so a note deleted later on another |
| | * device cannot be resurrected by a stale array — and if the browser |
| | * refuses to store, re-claiming is harmless anyway, because the ids are |
| | * unchanged and the server's save is an upsert. */ |
| | /* What a signing-in user may claim as their own: the bare array the old |
| | * gadget wrote, and the anonymous cache this one writes. Both mean "this |
| | * browser's notes, with nobody signed in" — which is what someone who |
| | * read logged out and then registered has. Without the second case, |
| | * signing in read them as unclaimable, then overwrote them with the |
| | * server's empty answer and they were gone for good. */ |
| | function legacy( key ) { |
| | var raw = readJson( key, null ); |
| | if ( Array.isArray( raw ) ) return raw; |
| | if ( raw && raw.u === null && Array.isArray( raw.v ) ) return raw.v; |
| | return []; |
| } | | } |
| | | function migrate() { |
| function init() { | | if ( !loggedIn ) return false; |
| var HIDE_IDS = ['vector-appearance', 'vector-appearance-pinned-container', 'vector-appearance-unpinned-container'];
| | var found = false; |
| function removeHiddenEls() {
| | legacy( NT_LS_KEY ).forEach( function ( n ) { |
| HIDE_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); });
| | if ( !n || !n.id ) return; |
| var pt = document.getElementById('vector-page-tools') || document.querySelector('.vector-page-tools-pinned-container');
| | // The old gadget imposed no length limit, so a note from before the |
| if (pt) pt.querySelectorAll('[aria-controls="vector-appearance"]').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
| | // upgrade can be past what the server accepts. Left in, it would sit |
| } | | // at the head of the queue; dropped here, it stays on screen and in |
| removeHiddenEls(); | | // the cache, just not synced. |
| (function detectTeekaMode() { | | if ( byteLength( n.text || '' ) > MAX_NOTE_BYTES ) return; |
| var tp = document.querySelector('.gr-teeka-page');
| | found = true; |
| if (!tp) return;
| | enqueue( { op: 'save', id: n.id, page: pageTitle, type: 'note', |
| var primary = tp.getAttribute('data-primary') || '';
| | quote: n.quote || '', text: n.text || '', ts: n.ts } ); |
| var artPath = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1';
| | } ); |
| var mainUrl = artPath.replace('$1', primary);
| | legacy( BM_LS_KEY ).forEach( function ( b ) { |
| var refParam = window.location.search.match(/[?&]ref=([01])/);
| | if ( !b || !b.id ) return; |
| if (refParam) { document.body.classList.add(refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone'); return; }
| | found = true; |
| var ref = document.referrer || '';
| | enqueue( { op: 'save', id: b.id, page: pageTitle, type: 'bookmark', |
| document.body.classList.add((ref && primary && ref.indexOf(mainUrl) !== -1) ? 'gr-ref-mode' : 'gr-standalone');
| | quote: b.quote || '', text: b.name || '', ts: b.ts } ); |
| }());
| | } ); |
| if (window.MutationObserver) {
| | return found; |
| var hideObs = new MutationObserver(function (mutations) {
| |
| var dirty = false;
| |
| mutations.forEach(function (m) { if (m.addedNodes.length) dirty = true; });
| |
| if (dirty) removeHiddenEls();
| |
| });
| |
| hideObs.observe(document.body, { childList: true, subtree: false });
| |
| setTimeout(function () { hideObs.disconnect(); }, 6000);
| |
| }
| |
| var content = document.querySelector('.mw-parser-output');
| |
| var alreadyTagged = content && content.querySelector('[data-deva]');
| |
| if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
| |
| else {
| |
| document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
| |
| }
| |
| var saved = (function () { try { return localStorage.getItem(LS_SCRIPT_KEY); } catch (e) { return null; } }());
| |
| if (saved && saved !== 'deva') { applyScript(saved); } else { currentScript = 'deva'; }
| |
| watchNewContent();
| |
| setTimeout(setupToc, 200);
| |
| } | | } |
|
| |
|
| window.addEventListener('gr-script-change', function (e) { | | /* ── Reconciliation ────────────────────────────────────────────── |
| var script = e && e.detail && e.detail.script;
| | * The server's answer describes the moment the request was sent. A note |
| if (script) applyScript(script);
| | * written while it was in the air, or a delete, is in the outbox and not |
| });
| | * in that answer — replacing state wholesale would take it off the |
| | | * screen and out of the cache, and the reader would watch their note |
| var _translitBusy = false;
| | * disappear. So anything still pending is re-applied on top. */ |
| function tagNewContent(container) {
| | function reconcile( notes, marks ) { |
| if (!container || !container.nodeType) return;
| | function apply( list, type ) { |
| _translitBusy = true;
| | var out = list.filter( function ( r ) { |
| try {
| | return !localDeletes[ r.id ] && !localSaves[ r.id ]; |
| var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
| | } ); |
| var nodes = [];
| | Object.keys( localSaves ).forEach( function ( id ) { |
| while (walker.nextNode()) nodes.push(walker.currentNode);
| | var s = localSaves[ id ]; |
| nodes.forEach(function (node) {
| | if ( s.type === type ) out.push( s.rec ); |
| var p = node.parentNode;
| | } ); |
| if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
| | return out; |
| if (p.closest && (p.closest('.gr-controls') || p.closest('.mw-editsection')
| | } |
| || p.closest('.gr-note-marker'))) return;
| | return { notes: apply( notes, 'note' ), marks: apply( marks, 'bookmark' ) }; |
| var orig = node.textContent;
| |
| if (!orig.trim()) return;
| |
| var span = document.createElement('span');
| |
| span.setAttribute('data-deva', orig);
| |
| span.textContent = currentScript !== 'deva' ? transliterateText(orig, currentScript) : orig;
| |
| p.replaceChild(span, node);
| |
| translatableSpans.push(span);
| |
| });
| |
| } finally { _translitBusy = false; }
| |
| }
| |
| window.addEventListener('gr-new-content', function (e) {
| |
| tagNewContent(e && e.detail && e.detail.container);
| |
| });
| |
| | |
| // Safety net: anything else that adds text to the page later — an
| |
| // interleaved commentary, a sub-commentary, an edit preview — is picked
| |
| // up and put into the reader's chosen script, rather than being left in
| |
| // Devanagari because it wasn't there when the page was first tagged.
| |
| function watchNewContent() { | |
| if (!window.MutationObserver) return;
| |
| var content = document.querySelector('.mw-parser-output');
| |
| if (!content || content.getAttribute('data-gr-translit-watch')) return;
| |
| content.setAttribute('data-gr-translit-watch', '1');
| |
| var queued = [], timer = null;
| |
| function flush() {
| |
| timer = null;
| |
| var items = queued; queued = [];
| |
| items.forEach(function (n) { if (document.body.contains(n)) tagNewContent(n); });
| |
| }
| |
| new MutationObserver(function (muts) {
| |
| if (_translitBusy) return;
| |
| muts.forEach(function (m) {
| |
| Array.prototype.forEach.call(m.addedNodes, function (n) {
| |
| var el = n.nodeType === 1 ? n : (n.nodeType === 3 ? n.parentNode : null);
| |
| if (!el || el.nodeType !== 1) return;
| |
| if (el.hasAttribute && el.hasAttribute('data-deva')) return;
| |
| queued.push(el);
| |
| });
| |
| });
| |
| if (queued.length && !timer) timer = setTimeout(flush, 60);
| |
| }).observe(content, { childList: true, subtree: true }); | |
| }
| |
| | |
| try {
| |
| var _grBC = new BroadcastChannel('gr-script'); | |
| _grBC.onmessage = function (e) {
| |
| var script = e && e.data && e.data.script;
| |
| if (script) { currentScript = script; var sel = document.querySelector('.gr-script-sel'); if (sel) sel.value = script; applyScript(script); }
| |
| };
| |
| } catch (e) { }
| |
| | |
| if (window.mw) {
| |
| mw.hook('wikipage.content').add(function () {
| |
| setTimeout(function () {
| |
| var content = document.querySelector('.mw-parser-output');
| |
| var alreadyTagged = content && content.querySelector('[data-deva]');
| |
| if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
| |
| else {
| |
| // tagTocEntry, not a re-tag from textContent: an entry
| |
| // keeps its Devanagari in a span *inside* .vector-toc-text,
| |
| // so .vector-toc-text itself never carries data-deva and a
| |
| // :not([data-deva]) sweep matched every entry on every
| |
| // fire. It then stored the text as it stood — Tamil, once
| |
| // the reader had switched — as that entry's "original", so
| |
| // the next switch had no Devanagari left to work from and
| |
| // the contents panel stayed in the previous script while
| |
| // the body moved on. tagTocEntry leaves a tagged entry be.
| |
| document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
| |
| }
| |
| if (currentScript !== 'deva') applyScript(currentScript);
| |
| setupToc();
| |
| }, 150);
| |
| });
| |
| }
| |
| | |
| if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); }
| |
| else { init(); }
| |
| | |
| }()); /* ← end of main IIFE */
| |
| | |
| | |
| // ── Inject "Help" and "About" links into the header ─────────────────
| |
| (function () {
| |
| function wikiHref(title) {
| |
| if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
| |
| var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1'; | |
| return ap.replace('$1', title); | |
| }
| |
| function makeHeaderLink(id, href, label) {
| |
| var a = document.createElement('a');
| |
| a.id = id; a.href = href; a.textContent = label;
| |
| a.className = 'gr-header-link'; // styled in Common.css §37
| |
| return a;
| |
| }
| |
| function injectHeaderLinks() {
| |
| if (document.getElementById('gr-about-link')) return;
| |
| var headerEnd = document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header');
| |
| if (!headerEnd) return;
| |
| var helpLink = makeHeaderLink('gr-help-link', wikiHref('My_wiki:Help'), 'Help');
| |
| var aboutLink = makeHeaderLink('gr-about-link', wikiHref('My_wiki:About'), 'About');
| |
| | |
| var userName = window.mw ? mw.config.get('wgUserName') : null;
| |
| var isAnon = !userName || (window.mw && mw.config.get('wgUserId') === null);
| |
| | |
| var authEl = null;
| |
| if (isAnon) {
| |
| if (!document.getElementById('gr-header-login')) {
| |
| authEl = makeHeaderLink('gr-header-login', wikiHref('Special:UserLogin'), 'Login');
| |
| }
| |
| } else if (!document.getElementById('gr-header-user')) {
| |
| authEl = buildHeaderUserDropdown(userName);
| |
| }
| |
| | |
| var ul = document.querySelector('.vector-user-links') || document.querySelector('#pt-userpage');
| |
| if (ul && ul.parentNode === headerEnd) {
| |
| if (authEl) headerEnd.insertBefore(authEl, ul);
| |
| headerEnd.insertBefore(aboutLink, authEl || ul);
| |
| headerEnd.insertBefore(helpLink, aboutLink);
| |
| } else {
| |
| headerEnd.appendChild(helpLink);
| |
| headerEnd.appendChild(aboutLink);
| |
| if (authEl) headerEnd.appendChild(authEl);
| |
| }
| |
| } | | } |
|
| |
|
| function buildHeaderUserDropdown(userName) { | | /* Fill _notes and _bookmarks. The cache is in place synchronously so the |
| var wrap = document.createElement('div');
| | * first paint is instant; the server's answer lands later. */ |
| wrap.id = 'gr-header-user';
| | function load() { |
| wrap.style.cssText = 'position:relative;display:inline-block;margin-right:4px;';
| | outbox = readJson( qKey(), [] ); |
| | if ( !Array.isArray( outbox ) ) outbox = []; |
|
| |
|
| var trigger = document.createElement('button');
| | if ( !loggedIn ) { |
| trigger.id = 'gr-header-user-trigger'; | | // localStorage is the store, not a cache. Accept the owner-stamped |
| trigger.type = 'button'; | | // shape and, once, the bare array the old gadget wrote. |
| trigger.setAttribute('aria-haspopup', 'true'); | | _notes = cached( NT_LS_KEY ); |
| trigger.setAttribute('aria-expanded', 'false'); | | _bookmarks = cached( BM_LS_KEY ); |
| trigger.style.cssText = [ | | if ( !_notes.length ) _notes = legacy( NT_LS_KEY ); |
| 'display:inline-flex', 'align-items:center', 'gap:5px',
| | if ( !_bookmarks.length ) _bookmarks = legacy( BM_LS_KEY ); |
| 'color:rgba(255,255,255,0.88)', 'font-size:0.88em',
| | return $.Deferred().resolve( false ).promise(); |
| 'font-family:system-ui,sans-serif', 'font-weight:500',
| | } |
| 'background:transparent', 'border:none', 'cursor:pointer',
| |
| 'padding:4px 10px', 'border-radius:4px', 'white-space:nowrap',
| |
| 'transition:color 0.15s,background 0.15s'
| |
| ].join(';');
| |
| trigger.innerHTML = | |
| '<span>' + userName.replace(/</g, '<') + '</span>' +
| |
| '<span id="gr-header-caret" style="font-size:0.8em;transition:transform 0.2s;">\u25be</span>';
| |
| trigger.addEventListener('mouseover', function () { this.style.color = '#fff'; this.style.background = 'rgba(255,255,255,0.12)'; });
| |
| trigger.addEventListener('mouseout', function () { if (menu.style.display === 'none') { this.style.color = 'rgba(255,255,255,0.88)'; this.style.background = 'transparent'; } }); | |
|
| |
|
| var menu = document.createElement('div'); | | if ( migrate() ) { |
| menu.id = 'gr-header-user-menu'; | | // Show what this browser already had straight away. migrate() has |
| menu.setAttribute('role', 'menu'); | | // just claimed it for whoever is signed in now, so what is on screen |
| menu.style.cssText = [ | | // and what is being sent up are the same set — and a first run that |
| 'display:none', 'position:absolute', 'top:100%', 'right:0', 'margin-top:4px',
| | // happens to be offline still shows the reader their notes rather |
| 'min-width:150px', 'background:#fff', 'border-radius:6px',
| | // than an empty panel. |
| 'box-shadow:0 4px 16px rgba(0,0,0,0.18)', 'overflow:hidden', 'z-index:1000'
| | _notes = legacy( NT_LS_KEY ); |
| ].join(';'); | | _bookmarks = legacy( BM_LS_KEY ); |
| | cache(); |
| | } else { |
| | fromCache(); |
| | } |
|
| |
|
| var itemStyle = 'display:block;padding:10px 16px;font-size:0.9em;color:#2c1810;text-decoration:none;font-family:system-ui,sans-serif;background:#fff;';
| | var d = $.Deferred(); |
| function makeMenuItem(href, label, id) {
| | var rows = []; |
| var a = document.createElement('a');
| |
| a.href = href; a.setAttribute('role', 'menuitem');
| |
| a.textContent = label; a.style.cssText = itemStyle;
| |
| if (id) a.id = id;
| |
| a.addEventListener('mouseover', function () { this.style.background = '#f5efe9'; });
| |
| a.addEventListener('mouseout', function () { this.style.background = '#fff'; });
| |
| return a;
| |
| }
| |
| | |
| menu.appendChild(makeMenuItem(wikiHref('Special:Profile'), 'Profile'));
| |
| menu.appendChild(makeMenuItem('#', 'Log out', 'gr-header-logout'));
| |
| | |
| function setOpen(open) {
| |
| menu.style.display = open ? 'block' : 'none';
| |
| trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
| |
| var caret = document.getElementById('gr-header-caret');
| |
| if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
| |
| if (open) { trigger.style.color = '#fff'; trigger.style.background = 'rgba(255,255,255,0.12)'; }
| |
| else { trigger.style.color = 'rgba(255,255,255,0.88)'; trigger.style.background = 'transparent'; }
| |
| }
| |
|
| |
|
| trigger.addEventListener('click', function (e) {
| | // 500 is the server's maximum page size, and it orders newest first — |
| e.stopPropagation();
| | // so a reader with more than that on one page would silently lose |
| setOpen(menu.style.display === 'none');
| | // their *oldest* annotations from the panel and from the cache. |
| }); | | // Follow the continuation until it stops rather than assuming nobody |
| document.addEventListener('click', function (e) {
| | // will get there. |
| if (!wrap.contains(e.target)) setOpen(false);
| | function fetchFrom( offset ) { |
| }); | | return ready().then( function ( a ) { |
| | var params = { |
| | action: 'granthaannotations', page: pageTitle, |
| | limit: 500, formatversion: 2, format: 'json' |
| | }; |
| | if ( offset ) params[ 'continue' ] = offset; |
| | return a.get( params ); |
| | } ).then( function ( data ) { |
| | var block = data && data.granthaannotations; |
| | var got = block && block.annotations; |
| | if ( Array.isArray( got ) ) rows = rows.concat( got ); |
| | // The module reports its own continuation, inside its own result. |
| | var next = block && block[ 'continue' ]; |
| | // Guard against a server that keeps handing back the same offset. |
| | if ( next !== undefined && next !== null && String( next ) !== String( offset ) && |
| | rows.length < 5000 ) { |
| | return fetchFrom( next ); |
| | } |
| | return rows; |
| | } ); |
| | } |
|
| |
|
| menu.querySelector('#gr-header-logout').addEventListener('click', function (e) {
| | flush().always( function () { |
| e.preventDefault();
| | fetchFrom( null ).done( function () { |
| if (window.mw && mw.user && mw.user.tokens) {
| | var notes = [], marks = []; |
| var t = mw.user.tokens.get('csrfToken');
| | rows.forEach( function ( r ) { |
| if (t) {
| | if ( r.type === 'bookmark' ) { |
| var script = (mw.util && mw.util.wikiScript) ? mw.util.wikiScript() : '/index.php';
| | marks.push( { id: r.id, name: r.text, quote: r.quote, ts: r.ts } ); |
| var form = document.createElement('form');
| |
| form.method = 'post';
| |
| form.action = script + '?title=Special:UserLogout';
| |
| form.style.display = 'none';
| |
| form.innerHTML =
| |
| '<input type="hidden" name="wpEditToken" value="' + String(t).replace(/"/g, '"') + '">' +
| |
| '<input type="hidden" name="title" value="Special:UserLogout">' +
| |
| '<input type="hidden" name="returnto" value="Main Page">';
| |
| document.body.appendChild(form);
| |
| form.submit();
| |
| return;
| |
| }
| |
| }
| |
| if (window.mw && mw.Api) {
| |
| new mw.Api().postWithToken('csrf', { action: 'logout' })
| |
| .done(function () { location.href = '/Main_Page'; })
| |
| .fail(function () { location.href = '/index.php?title=Special:UserLogout'; });
| |
| } else { | | } else { |
| location.href = '/index.php?title=Special:UserLogout';
| | notes.push( { id: r.id, ts: r.ts, quote: r.quote, text: r.text } ); |
| }
| |
| });
| |
| | |
| wrap.appendChild(trigger);
| |
| wrap.appendChild(menu);
| |
| return wrap;
| |
| }
| |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectHeaderLinks);
| |
| else injectHeaderLinks();
| |
| }());
| |
| | |
| | |
| // ── Main page: by-Grantha / by-Author toggle ──────────────────────
| |
| (function () {
| |
| function grHomeView(v) {
| |
| var gView = document.getElementById('gr-view-grantha');
| |
| var aView = document.getElementById('gr-view-author');
| |
| var gBtn = document.getElementById('gr-toggle-grantha');
| |
| var aBtn = document.getElementById('gr-toggle-author');
| |
| if (!gView || !aView || !gBtn || !aBtn) return;
| |
| gView.style.display = (v === 'grantha') ? '' : 'none';
| |
| aView.style.display = (v === 'author') ? '' : 'none';
| |
| gBtn.className = 'gr-toggle-btn' + (v === 'grantha' ? ' gr-toggle-active' : '');
| |
| aBtn.className = 'gr-toggle-btn' + (v === 'author' ? ' gr-toggle-active' : '');
| |
| try { localStorage.setItem('gr_home_view', v); } catch (e) { }
| |
| }
| |
| }());
| |
| | |
| | |
| // ── Ullekha reference link handler ─────────────────────────────────
| |
| (function () {
| |
| // Uses the shared, reflow-proof scroller when it is available.
| |
| function grScrollTo(el) {
| |
| if (!el) return;
| |
| if (window.grScrollToElement) window.grScrollToElement(el);
| |
| else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
| |
| }
| |
| | |
| function highlightOnArrival() {
| |
| var search = window.location.search;
| |
| if (!search) return;
| |
| var m = search.match(/[?&]hlUllekha=([^&]+)/);
| |
| if (!m) return;
| |
| var needle;
| |
| try { needle = decodeURIComponent(m[1]); } catch (e) { return; }
| |
| if (!needle || needle.length < 4) return;
| |
| function doHighlight() {
| |
| var content = document.querySelector('.mw-parser-output');
| |
| if (!content) return;
| |
| var snippet = needle.slice(0, 40);
| |
| var found = false;
| |
| var spans = content.querySelectorAll('[data-deva]');
| |
| for (var i = 0; i < spans.length && !found; i++) {
| |
| var spanEl = spans[i];
| |
| var orig = spanEl.getAttribute('data-deva') || '';
| |
| if (orig.indexOf(snippet) === -1) continue;
| |
| var idx = orig.indexOf(snippet);
| |
| var hlText = orig.slice(idx, Math.min(idx + needle.length, orig.length));
| |
| var mark = document.createElement('mark');
| |
| mark.className = 'gr-ullekha-highlight';
| |
| mark.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
| |
| mark.textContent = hlText;
| |
| var parent = spanEl.parentNode;
| |
| if (!parent) continue;
| |
| var before = document.createTextNode(orig.slice(0, idx));
| |
| var after = document.createTextNode(orig.slice(idx + hlText.length));
| |
| parent.insertBefore(before, spanEl); parent.insertBefore(mark, spanEl); parent.insertBefore(after, spanEl); parent.removeChild(spanEl);
| |
| setTimeout(function () { grScrollTo(mark); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark); }, 100);
| |
| found = true;
| |
| } | | } |
| if (!found) {
| | } ); |
| var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
| | // The server returns newest first; the panel reverses for display, |
| while (walker.nextNode() && !found) {
| | // so hold them oldest-first the way the local arrays always were. |
| var node = walker.currentNode;
| | var merged = reconcile( notes.reverse(), marks.reverse() ); |
| var txt = node.textContent || '';
| | _notes = merged.notes; |
| if (txt.indexOf(snippet) === -1) continue;
| | _bookmarks = merged.marks; |
| var idx2 = txt.indexOf(snippet);
| | // Folded in, so the local record of what has changed since can |
| var mark2 = document.createElement('mark');
| | // start again. Anything still unsent stays in the outbox. |
| mark2.className = 'gr-ullekha-highlight';
| | localSaves = {}; localDeletes = {}; |
| mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
| | cache(); |
| mark2.textContent = txt.slice(idx2, Math.min(idx2 + needle.length, txt.length));
| | d.resolve( true ); |
| var p = node.parentNode;
| | } ).fail( function () { |
| p.insertBefore(document.createTextNode(txt.slice(0, idx2)), node);
| | // Offline, or the extension is not installed. The cache already |
| p.insertBefore(mark2, node);
| | // loaded, and anything written meanwhile is queued. |
| p.insertBefore(document.createTextNode(txt.slice(idx2 + mark2.textContent.length)), node);
| | d.resolve( false ); |
| p.removeChild(node);
| | } ); |
| setTimeout(function () { grScrollTo(mark2); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark2); }, 100);
| | } ); |
| found = true;
| | return d.promise(); |
| }
| |
| }
| |
| }
| |
| doHighlight();
| |
| } | | } |
| function wireUllekhaLinks() {
| |
| document.querySelectorAll('.gr-ullekha-ref-link').forEach(function (wrap) {
| |
| var anchor = wrap.getAttribute('data-anchor') || '';
| |
| var hl = wrap.getAttribute('data-hl') || '';
| |
| var a = wrap.querySelector('a');
| |
| if (!a) return;
| |
| var base = a.href.split('#')[0];
| |
| var encoded = encodeURIComponent(hl);
| |
| a.href = base + (hl ? '?hlUllekha=' + encoded : '') + (anchor ? '#' + anchor : '');
| |
| });
| |
| }
| |
| if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { highlightOnArrival(); wireUllekhaLinks(); }); }
| |
| else { highlightOnArrival(); wireUllekhaLinks(); }
| |
| }());
| |
|
| |
|
| /* ── Search result highlight ──────────────────────────────────── */
| | if ( loggedIn && window.addEventListener ) { |
| (function () {
| | window.addEventListener( 'online', function () { flush(); } ); |
| | |
| function storeQueryForLink(url, query) { | |
| try {
| |
| var a = document.createElement('a');
| |
| a.href = url;
| |
| sessionStorage.setItem('gr_search_hl', JSON.stringify({
| |
| query: query,
| |
| pathname: a.pathname
| |
| }));
| |
| } catch (e) { }
| |
| } | | } |
|
| |
|
| function applyHighlight() { | | return { load: load, save: save, remove: remove, cache: cache, flush: flush, |
| var stored;
| | isRemote: function () { return loggedIn; } }; |
| try {
| | }() ); |
| stored = JSON.parse(sessionStorage.getItem('gr_search_hl') || 'null');
| |
| } catch (e) { return; }
| |
| if (!stored || !stored.query) return;
| |
|
| |
|
| var currentPath = window.location.pathname;
| | var $fab, $mobileBar, $panel, $backdrop; |
| var storedPath = stored.pathname || '';
| | var $ntComposer, $ntInput, $ntSubmit; |
| | var $bmComposer, $bmInput, $bmSubmit; |
| | var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; |
| | // CHANGED: added $tabFootnotes / $paneFootnotes alongside the existing |
| | // Notes/Bookmarks tab+pane refs, for the Footnotes tab. REMOVED: the |
| | // earlier $tabSandarbha/$paneSandarbha — that feature was replaced by |
| | // the real Ullekha citation-index mechanism (see quickedit.js), which |
| | // works entirely through common.js's own site-wide logic and needs no |
| | // panel integration here at all. |
| | var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes; |
|
| |
|
| function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); }
| | function buildDom() { |
| if (storedPath && normPath(storedPath) !== normPath(currentPath)) {
| | $fab = $( [ |
| try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
| | '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">', |
| return;
| | ' <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Note">', |
| }
| | ' <span class="gra-icon gra-icon-note" aria-hidden="true"></span>', |
| | ' <span class="gra-fab-btn-label">Note</span>', |
| | ' </button>', |
| | ' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Mark">', |
| | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', |
| | ' <span class="gra-fab-btn-label">Mark</span>', |
| | ' </button>', |
| | ' <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Feedback">', |
| | ' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', |
| | ' <span class="gra-fab-btn-label">Feedback</span>', |
| | ' </button>', |
| | ' <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search">', |
| | ' <span class="gra-icon gra-icon-search" aria-hidden="true"></span>', |
| | ' <span class="gra-fab-btn-label">Search</span>', |
| | ' </button>', |
| | ' <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">', |
| | ' <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>', |
| | ' <span class="gra-fab-btn-label">Close</span>', |
| | ' </button>', |
| | '</div>', |
| | ].join('') ); |
| | $('body').append($fab); |
|
| |
|
| var query = stored.query.trim();
| | $mobileBar = $('<div id="gra-mobile-bar"></div>'); |
| if (!query) return;
| | $('body').append($mobileBar); |
|
| |
|
| try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
| | $fbComposer = $( [ |
| | '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">', |
| | ' <div class="gra-composer-header">', |
| | ' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', |
| | ' <strong>Feedback</strong>', |
| | ' <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>', |
| | ' </div>', |
| | ' <div class="gra-fb-quote-label">Selected text:</div>', |
| | ' <div class="gra-fb-quote" id="gra-fb-quote"></div>', |
| | ' <div class="gra-fb-field-label">Issue type</div>', |
| | ' <select class="gra-fb-select" id="gra-fb-issue">', |
| | ' <option value="">— Choose —</option>', |
| | ' <option value="wrong_text">Formatting error</option>', |
| | ' <option value="reference_issue">Reference issue</option>', |
| | ' <option value="spelling_mistake">Spelling mistake</option>', |
| | ' <option value="other">Other</option>', |
| | ' </select>', |
| | ' <div class="gra-fb-field-label">Details (optional)</div>', |
| | ' <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>', |
| | ' <div class="gra-fb-field-label">Your email (optional)</div>', |
| | ' <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">', |
| | ' <div class="gra-composer-actions">', |
| | ' <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>', |
| | ' <button class="gra-btn-submit" id="gra-fb-submit" disabled>Send</button>', |
| | ' </div>', |
| | ' <div class="gra-fb-status" id="gra-fb-status"></div>', |
| | '</div>', |
| | ].join('') ); |
| | $('body').append($fbComposer); |
|
| |
|
| var delays = [0, 200, 600];
| | $ntComposer = $( [ |
| delays.forEach(function (ms) {
| | '<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">', |
| setTimeout(function () {
| | ' <div class="gra-composer-user">', |
| if (document.querySelector('.gr-search-hl')) return;
| | ' <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>', |
| highlightText(query);
| | ' <div class="gra-composer-uname">' + esc(currentUser || 'Notes') + '</div>', |
| }, ms);
| | ' </div>', |
| });
| | ' <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>', |
| }
| | ' <div class="gra-composer-actions">', |
| | ' <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>', |
| | ' <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>', |
| | ' </div>', |
| | '</div>', |
| | ].join('') ); |
| | $('body').append($ntComposer); |
|
| |
|
| // Uses the shared, reflow-proof scroller when it is available. | | $bmComposer = $( [ |
| function grScrollTo(el) { | | '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">', |
| if (!el) return;
| | ' <div class="gra-bm-composer-label">', |
| if (window.grScrollToElement) window.grScrollToElement(el);
| | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', |
| else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
| | ' Save bookmark', |
| }
| | ' </div>', |
| | ' <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">', |
| | ' <div class="gra-composer-actions">', |
| | ' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>', |
| | ' <button class="gra-btn-submit" id="gra-bm-submit">Save</button>', |
| | ' </div>', |
| | '</div>', |
| | ].join('') ); |
| | $('body').append($bmComposer); |
|
| |
|
| function highlightText(query) { | | $panel = $( [ |
| var content = document.querySelector('#mw-content-text .mw-parser-output');
| | '<div id="gra-panel" role="complementary" aria-label="Notes">', |
| if (!content) return;
| | ' <div id="gra-panel-head">', |
| | ' <div id="gra-panel-title"></div>', |
| | ' <button id="gra-panel-close" title="Close">✕</button>', |
| | ' </div>', |
| | ' <div id="gra-tabs">', |
| | ' <button class="gra-tab gra-tab-active" id="gra-tab-notes">', |
| | ' <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes', |
| | ' </button>', |
| | ' <button class="gra-tab" id="gra-tab-bookmarks">', |
| | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', |
| | ' </button>', |
| | ' <button class="gra-tab" id="gra-tab-footnotes">', |
| | ' <span class="gra-icon gra-icon-footnote" aria-hidden="true"></span> Footnotes', |
| | ' </button>', |
| | ' </div>', |
| | ' <div id="gra-panel-body">', |
| | ' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', |
| | ' <div class="gra-pane" id="gra-pane-bookmarks"></div>', |
| | ' <div class="gra-pane" id="gra-pane-footnotes"></div>', |
| | ' </div>', |
| | '</div>', |
| | ].join('') ); |
| | $('body').append($panel); |
|
| |
|
| var raw = query.replace(/^"|"$/g, '').trim();
| | $backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>'); |
| if (!raw) return;
| | $('body').append($backdrop); |
|
| |
|
| var patterns = [];
| | var $toggle = $( [ |
| patterns.push(escapeRegex(raw));
| | '<button id="gra-toggle" aria-label="Notes">', |
| raw.split(/\s+/).forEach(function (w) {
| | ' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>', |
| if (w.length >= 2) patterns.push(escapeRegex(w));
| | ' <span id="gra-toggle-badge" aria-live="polite"></span>', |
| });
| | '</button>', |
| | ].join('') ); |
| | $('body').append($toggle); |
| | $toggle.on('click', function() { |
| | $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab); |
| | }); |
|
| |
|
| var matched = false;
| | $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30)); |
| for (var pi = 0; pi < patterns.length; pi++) {
| | $tabNotes = $('#gra-tab-notes'); |
| var re;
| | $tabBookmarks = $('#gra-tab-bookmarks'); |
| try { re = new RegExp('(' + patterns[pi] + ')', 'gi'); }
| | $tabFootnotes = $('#gra-tab-footnotes'); |
| catch (e) { continue; }
| | $paneNotes = $('#gra-pane-notes'); |
| var count = wrapMatches(content, re);
| | $paneBookmarks= $('#gra-pane-bookmarks'); |
| if (count > 0) { matched = true; break; }
| | $paneFootnotes= $('#gra-pane-footnotes'); |
| }
| | $ntInput = $('#gra-nt-input'); |
| | $ntSubmit = $('#gra-nt-submit'); |
| | $bmInput = $('#gra-bm-input'); |
| | $bmSubmit = $('#gra-bm-submit'); |
| | $fbIssueType = $('#gra-fb-issue'); |
| | $fbText = $('#gra-fb-text'); |
| | $fbEmail = $('#gra-fb-email'); |
| | $fbSubmit = $('#gra-fb-submit'); |
| | $fbQuote = $('#gra-fb-quote'); |
| | } |
|
| |
|
| if (!matched) return;
| | function captureSelection() { |
| | var sel = window.getSelection(); |
| | if (!sel || sel.isCollapsed || !sel.rangeCount) return false; |
| | var range = sel.getRangeAt(0); |
| | var text = sel.toString().trim(); |
| | if (!text || text.length < 2) return false; |
| | var contentEl = document.querySelector(CONTENT_SEL); |
| | if (!contentEl) return false; |
| | var ancestor = range.commonAncestorContainer; |
| | if (ancestor.nodeType === 3) ancestor = ancestor.parentNode; |
| | if (!ancestor || !contentEl.contains(ancestor)) return false; |
| | var _editorEl = document.getElementById('se-surface') || |
| | document.querySelector('.se-outer'); |
| | if ( _editorEl && _editorEl.contains(ancestor) ) return false; |
| | if ( document.body.classList.contains('gra-editing') ) return false; |
| | _selText = text; |
| | _selRect = range.getBoundingClientRect(); |
| | try { _selRange = range.cloneRange(); } |
| | catch(e){ _selRange = null; } |
| | return true; |
| | } |
|
| |
|
| var first = document.querySelector('.gr-search-hl');
| | function reCaptureFromDOM() { |
| if (first) {
| | if (!_selText) return false; |
| grScrollTo(first);
| | var contentEl = document.querySelector(CONTENT_SEL); |
| first.classList.add('gr-search-hl-pulse');
| | if (!contentEl) return false; |
| setTimeout(function () { first.classList.remove('gr-search-hl-pulse'); }, 2000);
| | var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim()); |
| // NEW: directly trigger the TOC to expand and highlight the
| | if (!found) return false; |
| // right section, rather than relying solely on the scroll
| | _selRange = found; |
| // observer to catch up on its own. This is the actual fix —
| | return true; |
| // confirmed the helper against a realistic multi-section
| | } |
| // fragment before wiring it in here.
| |
| if (window.grHighlightTocForElement) window.grHighlightTocForElement(first);
| |
| }
| |
|
| |
|
| showDismissBar(query);
| | function tryShowActions() { |
| } | | if ($fbComposer && $fbComposer.hasClass('gra-composer-visible')) return; |
| | if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return; |
| | if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return; |
| | if (!captureSelection()) { hideActions(); return; } |
| | _fabSelVer = _selVersion; |
| | showFab(_selRect); |
| | } |
|
| |
|
| function escapeRegex(s) {
| | function showFab(rect) { |
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
| | if (!rect) return; |
| | var fabW, fabH, top, left; |
| | if (_mobile) { |
| | $fab.css({ position: '', top: '', left: '', visibility: '' }) |
| | .addClass('gra-fab-visible gra-fab-mobile-docked'); |
| | return; |
| } | | } |
| | fabW = 46; fabH = 126; |
| | top = rect.top + (rect.height / 2) - (fabH / 2); |
| | left = rect.right + 10; |
| | if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10; |
| | top = clamp(top, 8, window.innerHeight - fabH - 8); |
| | left = clamp(left, 8, window.innerWidth - fabW - 8); |
| | $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible'); |
| | } |
|
| |
|
| function wrapMatches(root, re) {
| | function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); } |
| var count = 0;
| | function hideActions() { hideFab(); } |
| var walker = document.createTreeWalker(
| |
| root, NodeFilter.SHOW_TEXT, {
| |
| acceptNode: function (node) {
| |
| var p = node.parentElement;
| |
| if (!p) return NodeFilter.FILTER_REJECT;
| |
| var tag = p.tagName.toUpperCase();
| |
| if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT;
| |
| if (p.classList.contains('gr-search-hl')) return NodeFilter.FILTER_REJECT;
| |
| return NodeFilter.FILTER_ACCEPT;
| |
| }
| |
| }, false
| |
| );
| |
|
| |
|
| var nodes = [];
| | function wrapSelection(id, cssClass) { |
| var node;
| | var range = _selRange; |
| while ((node = walker.nextNode())) nodes.push(node);
| | _selRange = null; |
| | | if (!range) return null; |
| nodes.forEach(function (textNode) {
| | try { |
| var val = textNode.nodeValue;
| | if (!document.contains(range.startContainer) || |
| if (!re.test(val)) return;
| | !document.contains(range.endContainer)) return null; |
| re.lastIndex = 0;
| | } catch(e) { return null; } |
| | | function makeSpan() { |
| var frag = document.createDocumentFragment();
| | var sp = document.createElement('span'); |
| var last = 0;
| | sp.className = cssClass; |
| var m;
| | sp.setAttribute('data-gra-id', id); |
| while ((m = re.exec(val)) !== null) {
| | return sp; |
| if (m.index > last) {
| |
| frag.appendChild(document.createTextNode(val.slice(last, m.index)));
| |
| }
| |
| var span = document.createElement('span');
| |
| span.className = 'gr-search-hl';
| |
| span.textContent = m[0];
| |
| frag.appendChild(span);
| |
| last = m.index + m[0].length;
| |
| count++;
| |
| }
| |
| if (last < val.length) {
| |
| frag.appendChild(document.createTextNode(val.slice(last)));
| |
| }
| |
| textNode.parentNode.replaceChild(frag, textNode);
| |
| });
| |
| | |
| return count;
| |
| } | | } |
| | try { |
| | var span = makeSpan(); |
| | range.surroundContents(span); |
| | if (span.parentNode) return span; |
| | } catch(e) {} |
| | try { |
| | var frag = range.extractContents(); |
| | var sp2 = makeSpan(); |
| | sp2.appendChild(frag); |
| | range.insertNode(sp2); |
| | if (sp2 && sp2.parentNode) return sp2; |
| | } catch(e2) {} |
| | return null; |
| | } |
|
| |
|
| function showDismissBar(query) {
| | function openFeedbackComposer() { |
| var existing = document.getElementById('gr-hl-bar');
| | hideActions(); |
| if (existing) existing.remove();
| | $fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : '')); |
| | $fbIssueType.val(''); |
| | $fbText.val(''); |
| | $fbSubmit.prop('disabled', true); |
| | $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); |
| | if (currentUserEmail) $fbEmail.val(currentUserEmail); |
| | else $fbEmail.val(''); |
| | if (!_mobile) $fbComposer.css({top:'', left:'', transform:''}); |
| | $fbComposer.addClass('gra-composer-visible'); |
| | $backdrop.addClass('gra-backdrop-visible'); |
| | setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0); |
| | } |
|
| |
|
| var count = document.querySelectorAll('.gr-search-hl').length;
| | function closeFeedbackComposer() { |
| if (!count) return;
| | $fbComposer.removeClass('gra-composer-visible'); |
| | $backdrop.removeClass('gra-backdrop-visible'); |
| | _selRange = null; _selText = ''; _selRect = null; |
| | } |
|
| |
|
| var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
| | function submitFeedback() { |
| var bar = document.createElement('div');
| | var issueType = $fbIssueType.val(); |
| bar.id = 'gr-hl-bar';
| | var details = $fbText.val().trim(); |
| | | var email = $fbEmail.val().trim(); |
| if (isMob) {
| | var quote = $fbQuote.text(); |
| bar.className = 'gr-search-hl-mobile';
| | if (!issueType) return; |
| | | $fbSubmit.prop('disabled', true).text('Sending…'); |
| bar.innerHTML =
| | $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); |
| '<button id="gr-hl-results" class="gr-search-bar-btn">' +
| | var issueLabels = { |
| '<span class="gra-icon gra-icon-search" aria-hidden="true"></span>' +
| | wrong_text: 'Formatting error', reference_issue: 'Reference issue', |
| '<span>' + count + ' match' + (count === 1 ? '' : 'es') + '</span></button>' +
| | spelling_mistake: 'Spelling mistake', other: 'Other' |
| '<button id="gr-hl-prev" class="gr-search-bar-btn">' +
| | }; |
| '<span class="gra-icon gra-icon-prev" aria-hidden="true"></span>' +
| | var payload = new FormData(); |
| '<span>Prev</span></button>' +
| | payload.append('issue_type', issueLabels[issueType] || issueType); |
| '<button id="gr-hl-next" class="gr-search-bar-btn">' +
| | payload.append('page', pageTitle.replace(/_/g,' ')); |
| '<span class="gra-icon gra-icon-next" aria-hidden="true"></span>' +
| | payload.append('url', window.location.href); |
| '<span>Next</span></button>' +
| | payload.append('selected_text', quote); |
| '<button id="gr-hl-dismiss" class="gr-search-bar-btn">' +
| | payload.append('details', details || ''); |
| '<span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>' +
| | payload.append('user_email', email || currentUserEmail || ''); |
| '<span>Close</span></button>';
| | payload.append('wiki_user', currentUser || 'anonymous'); |
| } else {
| | fetch('/feedback.php', {method:'POST', body:payload}) |
| bar.style.cssText = [
| | .then(function(r){ return r.json(); }) |
| 'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200',
| | .then(function(data){ |
| 'background:#b5451b', 'color:#fff', 'padding:10px 16px',
| | if (data && data.ok) showFeedbackSuccess(); |
| 'display:flex', 'align-items:center', 'justify-content:space-between',
| | else showFeedbackError(data && data.error ? data.error : 'Could not send.'); |
| 'font-family:system-ui,sans-serif', 'font-size:14px',
| | }) |
| 'box-shadow:0 -2px 8px rgba(0,0,0,0.2)'
| | .catch(function(){ showFeedbackError('Network error. Please try again.'); }); |
| ].join(';');
| | } |
| | |
| var nav = document.createElement('div');
| |
| nav.style.cssText = 'display:flex;align-items:center;gap:12px;';
| |
| nav.innerHTML =
| |
| '<span>🔍 <strong>' + escHtml(query) + '</strong> — ' + count + ' match' + (count === 1 ? '' : 'es') + '</span>' +
| |
| '<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↑ Prev</button>' +
| |
| '<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↓ Next</button>' +
| |
| '<button id="gr-hl-results" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">← Results</button>';
| |
| bar.appendChild(nav);
| |
|
| |
|
| var db = document.createElement('button');
| | function showFeedbackSuccess() { |
| db.id = 'gr-hl-dismiss'; db.textContent = '✕ Close';
| | $fbSubmit.prop('disabled', false).text('Send'); |
| db.style.cssText = 'background:rgba(255,255,255,0.15);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;';
| | $('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok'); |
| bar.appendChild(db);
| | setTimeout(closeFeedbackComposer, 2500); |
| }
| | } |
| | function showFeedbackError(msg) { |
| | $fbSubmit.prop('disabled', false).text('Send'); |
| | $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err'); |
| | } |
|
| |
|
| document.body.appendChild(bar);
| | function openNoteComposer() { |
| | hideActions(); |
| | if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' }); |
| | $ntComposer.addClass('gra-composer-visible'); |
| | $backdrop.addClass('gra-backdrop-visible'); |
| | setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0); |
| | } |
|
| |
|
| var hlEls = Array.from(document.querySelectorAll('.gr-search-hl'));
| | function closeNoteComposer() { |
| var current = 0;
| | $ntComposer.removeClass('gra-composer-visible'); |
| | $backdrop.removeClass('gra-backdrop-visible'); |
| | $ntInput.val(''); |
| | $ntSubmit.prop('disabled', true); |
| | _selRange = null; _selText = ''; _selRect = null; |
| | } |
|
| |
|
| function goTo(idx) {
| | function submitNote() { |
| hlEls.forEach(function (el) { el.classList.remove('gr-search-hl-current'); });
| | var text = $ntInput.val().trim(); |
| current = ((idx % hlEls.length) + hlEls.length) % hlEls.length;
| | if (!text) return; |
| hlEls[current].classList.add('gr-search-hl-current');
| | if (byteLength(text) > MAX_NOTE_BYTES) { |
| grScrollTo(hlEls[current]);
| | alert('This note is too long to save — please shorten it.'); |
| // NEW: same TOC sync on Prev/Next navigation within the
| | return; |
| // results, not just the initial jump.
| |
| if (window.grHighlightTocForElement) window.grHighlightTocForElement(hlEls[current]);
| |
| }
| |
| | |
| function dismiss() { clearHighlights(); bar.remove(); }
| |
| | |
| var nb = document.getElementById('gr-hl-next');
| |
| var pb = document.getElementById('gr-hl-prev');
| |
| var rb = document.getElementById('gr-hl-results');
| |
| var db2 = document.getElementById('gr-hl-dismiss');
| |
| if (nb) nb.onclick = function () { goTo(current + 1); };
| |
| if (pb) pb.onclick = function () { goTo(current - 1); };
| |
| if (db2) db2.onclick = dismiss;
| |
| if (rb) rb.onclick = function () {
| |
| bar.remove(); clearHighlights();
| |
| if (window.showSearchDialog) window.showSearchDialog(query);
| |
| };
| |
| } | | } |
| | var id = uid(); |
| | var ts = nowIso(); |
| | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); |
| | if (!_selRange && _selText) reCaptureFromDOM(); |
| | var span = wrapSelection(id, 'gra-note-highlight'); |
| | if (span) span.setAttribute('data-gra-quote', quote); |
| | var rec = {id:id, ts:ts, quote:quote, text:text}; |
| | _notes.push(rec); |
| | Store.cache(); |
| | Store.save('note', rec); |
| | renderNoteCards(); updateBadge(); |
| | closeNoteComposer(); |
| | openPanel('notes'); |
| | } |
|
| |
|
| function clearHighlights() {
| | function openBookmarkComposer() { |
| document.querySelectorAll('.gr-search-hl').forEach(function (span) {
| | hideActions(); |
| var p = span.parentNode; if (!p) return;
| | if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' }); |
| while (span.firstChild) p.insertBefore(span.firstChild, span);
| | $bmComposer.addClass('gra-composer-visible'); |
| p.removeChild(span);
| | $backdrop.addClass('gra-backdrop-visible'); |
| });
| | setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0); |
| }
| | } |
|
| |
|
| function escHtml(s) {
| | function closeBookmarkComposer() { |
| return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
| | $bmComposer.removeClass('gra-composer-visible'); |
| } | | $backdrop.removeClass('gra-backdrop-visible'); |
| | | $bmInput.val(''); |
| function injectHighlightCSS() {
| | _selRange = null; _selText = ''; _selRect = null; |
| if (document.getElementById('gr-hl-css')) return;
| | } |
| var s = document.createElement('style');
| |
| s.id = 'gr-hl-css';
| |
| s.textContent = [
| |
| '.gr-search-hl{background:#fff176;color:#1a1a1a;border-radius:2px;padding:0 1px;box-shadow:0 0 0 1px rgba(181,69,27,0.25);}',
| |
| '.gr-search-hl-current{background:#ffb300!important;box-shadow:0 0 0 2px #b5451b!important;}',
| |
| '@keyframes gr-hl-pulse{0%{background:#ffb300;}50%{background:#fff176;}100%{background:#fff176;}}',
| |
| '.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}',
| |
| ].join('');
| |
| document.head.appendChild(s);
| |
| }
| |
|
| |
|
| injectHighlightCSS(); | | function submitBookmark() { |
| | | var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1)); |
| if (document.readyState === 'loading') { | | // A bookmark's name is stored in the same column as a note's text and |
| document.addEventListener('DOMContentLoaded', applyHighlight);
| | // is capped the same way. |
| } else {
| | if (byteLength(name) > MAX_NOTE_BYTES) { |
| applyHighlight();
| | alert('This bookmark name is too long to save — please shorten it.'); |
| | return; |
| } | | } |
| | var id = uid(); |
| | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); |
| | if (!_selRange && _selText) reCaptureFromDOM(); |
| | var span = wrapSelection(id, 'gra-bookmark-highlight'); |
| | if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); } |
| | var rec = {id:id, name:name, quote:quote, ts:nowIso()}; |
| | _bookmarks.push(rec); |
| | Store.cache(); |
| | Store.save('bookmark', rec); |
| | renderBookmarkCards(); updateBadge(); |
| | closeBookmarkComposer(); |
| | openPanel('bookmarks'); |
| | } |
|
| |
|
| if (window.mw) { | | function deleteBookmark(id) { |
| mw.hook('wikipage.content').add(function () {
| | _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; }); |
| setTimeout(applyHighlight, 100);
| | var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight'); |
| });
| | if (span && span.parentNode) { |
| | var p = span.parentNode; |
| | while (span.firstChild) p.insertBefore(span.firstChild, span); |
| | p.removeChild(span); |
| } | | } |
| | Store.cache(); Store.remove(id); renderBookmarkCards(); updateBadge(); |
| | } |
|
| |
|
| window.grStoreSearchHL = storeQueryForLink; | | function openPanel(tab) { |
| | _activeTab = tab || _activeTab; |
| | switchTab(_activeTab); |
| | $panel.addClass('gra-panel-open'); |
| | $backdrop.addClass('gra-backdrop-visible'); |
| | } |
| | function closePanel() { |
| | $panel.removeClass('gra-panel-open'); |
| | $backdrop.removeClass('gra-backdrop-visible'); |
| | } |
| | // CHANGED: added the footnotes case, alongside notes/bookmarks. |
| | function switchTab(tab) { |
| | _activeTab = tab; |
| | $tabNotes.toggleClass('gra-tab-active', tab==='notes'); |
| | $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); |
| | $tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes'); |
| | $paneNotes.toggleClass('gra-pane-active', tab==='notes'); |
| | $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); |
| | $paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes'); |
| | if (tab==='notes') renderNoteCards(); |
| | else if (tab==='bookmarks') renderBookmarkCards(); |
| | else renderFootnoteCards(); |
| | } |
|
| |
|
| }());
| | function renderNoteCards() { |
| | | if (!_notes.length) { |
| /* ═══════════════════════════════════════════════════════════════
| | $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>'); |
| Mobile addon — only runs on Minerva (mobile) skin
| | return; |
| ═══════════════════════════════════════════════════════════════ */
| |
| (function () {
| |
| if (!document.body.classList.contains('skin-minerva')) return; | |
| function injectMenuLinks() {
| |
| if (document.getElementById('gr-mob-menu-items')) return;
| |
| var navDrawer = document.querySelector('.navigation-drawer');
| |
| if (!navDrawer) return;
| |
| var wrap = document.createElement('div'); wrap.id = 'gr-mob-menu-items'; wrap.className = 'gr-mob-menu';
| |
| function makeItem(href, label) { var a = document.createElement('a'); a.href = href; a.className = 'gr-mob-item'; a.textContent = label; return a; }
| |
| wrap.appendChild(makeItem('/Main_Page', 'Home'));
| |
| wrap.appendChild(makeItem('/My_wiki:Help', 'Help'));
| |
| wrap.appendChild(makeItem('/My_wiki:About', 'About'));
| |
| var userName = window.mw ? mw.config.get('wgUserName') : null;
| |
| if (userName) {
| |
| wrap.appendChild(makeItem((window.mw && mw.util && mw.util.getUrl) ? mw.util.getUrl('Special:Profile') : '/Special:Profile', 'Profile'));
| |
| var la = document.querySelector('a[href*="action=logout"]');
| |
| wrap.appendChild(makeItem(la ? la.href : '/index.php?title=Special:UserLogout', 'Log out'));
| |
| }
| |
| else wrap.appendChild(makeItem('/index.php?title=Special:UserLogin', 'Log in'));
| |
| var pageLeft = document.getElementById('mw-mf-page-left');
| |
| if (pageLeft) {
| |
| // Hide Minerva's own menu lists rather than deleting them: Minerva's
| |
| // initMobile.js looks up .toggle-list__list inside this panel and
| |
| // throws ("Cannot read properties of null (reading 'parentNode')")
| |
| // when it is gone, which also stops the rest of Minerva's setup.
| |
| Array.prototype.forEach.call(pageLeft.children, function (c) { c.classList.add('gr-mf-native-menu'); });
| |
| pageLeft.style.removeProperty('display');
| |
| pageLeft.appendChild(wrap);
| |
| }
| |
| else navDrawer.appendChild(wrap);
| |
| } | | } |
| function injectMoolaUllekhaLinks() { | | var html = ''; |
| if (document.getElementById('gr-mob-doc-nav')) return;
| | _notes.slice().reverse().forEach(function(n){ |
| var pageName = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
| | html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">' |
| if (pageName === 'Main_Page' || !pageName) return;
| | + '<div class="gra-card-header">' |
| function wikiUrl(slug) { if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug); return ((window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1').replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/')); }
| | + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>' |
| var teekaPage = document.querySelector('.gr-teeka-page');
| | + '<div class="gra-card-meta">' |
| var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || pageName.split('/')[0]) : pageName.split('/')[0];
| | + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '') |
| var docTitleEl = document.querySelector('.gr-doc-title');
| | + '</div>' |
| var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1';
| | + '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>' |
| var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1';
| | + '</div>' |
| var showMoolam = !!teekaPage || hasMoolaPage, showUllekha = hasUllekhaPage || !!teekaPage;
| | + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '') |
| if (!showMoolam && !showUllekha) return;
| | + '<div class="gra-card-text">'+esc(n.text)+'</div>' |
| var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
| | + '</div>'; |
| nav.className = 'toc-main-links'; // styled in Common.css §24
| |
| function makeLink(href, label) { var a = document.createElement('a'); a.href = href; a.textContent = label; a.className = 'toc-main-link-item'; return a; }
| |
| if (teekaPage) nav.appendChild(makeLink(wikiUrl(primarySlug), 'मूल'));
| |
| else if (hasMoolaPage) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
| |
| if (showUllekha) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
| |
| // Right under the document's own title, which is what a reader on a
| |
| // phone actually sees as the title; the skin's h1 is the fallback.
| |
| var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
| |
| var h1 = document.getElementById('firstHeading') || document.querySelector('.page-heading, h1.firstHeading, .mw-first-heading');
| |
| if (docTitle && docTitle.parentNode) docTitle.parentNode.insertBefore(nav, docTitle.nextSibling);
| |
| else if (h1 && h1.parentNode) h1.parentNode.insertBefore(nav, h1.nextSibling);
| |
| else { var ct = document.getElementById('mw-content-text'); if (ct) ct.insertBefore(nav, ct.firstChild); }
| |
| }
| |
| function boot() { injectMenuLinks(); injectMoolaUllekhaLinks();}
| |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
| |
| else boot();
| |
| if (window.mw) mw.hook('wikipage.content');
| |
| }());
| |
| | |
| (function () {
| |
| 'use strict';
| |
| if (mw.config.get('wgPageName') !== 'Main_Page') return;
| |
| mw.loader.using('mediawiki.util').done(function () {
| |
| $(function () { applyHomeToggleOffset(); window.addEventListener('resize', applyHomeToggleOffset, { passive: true }); setTimeout(applyHomeToggleOffset, 300); setTimeout(applyHomeToggleOffset, 800); });
| |
| }); | | }); |
| function applyHomeToggleOffset() { | | $paneNotes.html(html); |
| var bar = document.getElementById('gr-static-bar'); if (!bar) return;
| | } |
| var barBottom = Math.round(bar.getBoundingClientRect().bottom);
| |
| [document.getElementById('gr-home'), document.getElementById('gr-home-toggle'), document.querySelector('#mw-content-text .mw-parser-output > .gr-home, #mw-content-text .mw-parser-output > *:first-child')].forEach(function (el) { if (el) el.style.scrollMarginTop = (barBottom + 4) + 'px'; });
| |
| var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
| |
| if (isMob) { var toggleEl = document.getElementById('gr-home-toggle'); if (toggleEl) { var tr = toggleEl.getBoundingClientRect(); if (tr.top < barBottom) { var cp = parseInt(window.getComputedStyle(document.body).paddingTop, 10) || 0; document.body.style.paddingTop = (cp + (barBottom - tr.top) + 4) + 'px'; } } }
| |
| }
| |
| $(function () {
| |
| var $toggle = $('#gr-home-toggle'), $viewG = $('#gr-view-grantha'), $viewA = $('#gr-view-author'), $btnG = $('#gr-toggle-grantha'), $btnA = $('#gr-toggle-author');
| |
| if (!$toggle.length || !$viewG.length || !$viewA.length) return; if ($toggle.data('gr-wired')) return; $toggle.data('gr-wired', true);
| |
| function showView(which) { if (which === 'grantha') { $viewG.show(); $viewA.hide(); $btnG.addClass('gr-toggle-active'); $btnA.removeClass('gr-toggle-active'); } else { $viewA.show(); $viewG.hide(); $btnA.addClass('gr-toggle-active'); $btnG.removeClass('gr-toggle-active'); } try { localStorage.setItem('grantha_home_tab', which); } catch (e) { } }
| |
| $btnG.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('grantha'); });
| |
| $btnA.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('author'); });
| |
| try { var saved = localStorage.getItem('grantha_home_tab'); if (saved === 'author') showView('author'); else showView('grantha'); } catch (e) { showView('grantha'); }
| |
| });
| |
| }());
| |
| // ── Replace MediaWiki user links with a single username dropdown (Profile + Logout) ──
| |
| (function () {
| |
| function wikiHref(title) {
| |
| if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
| |
| var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
| |
| return ap.replace('$1', title);
| |
| }
| |
| }());
| |
| /* ═══════════════════════════════════════════════════════════════
| |
| Commentary interleaving — discovers a document's /Vyakhya or
| |
| /Bhashya commentary subpages, lets a reader select which ones to
| |
| show interleaved with the moola text, and augments any existing
| |
| link to a commentary (e.g. a "वादावली" list) with its own inline
| |
| checkbox. Exposes window.grTeekaInterleave so readerToolbar.js's
| |
| "Commentaries" dropdown can stay in sync without duplicating this
| |
| logic — same pattern as window.grHighlightTocForElement above.
| |
| ═══════════════════════════════════════════════════════════════ */
| |
| (function () {
| |
| | |
| var LS_TEEKA_KEY = 'grantha_reader_teeka_in_moola';
| |
|
| |
|
| // Distinct light background per commentary, for when multiple
| | function deleteNote(id) { |
| // different teekas are interleaved together and need to stay
| | _notes = _notes.filter(function(n){ return n.id !== id; }); |
| // visually distinguishable. CHANGED: was a 30-color palette
| | var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight'); |
| // transcribed from a screenshot - confirmed directly that at least
| | if (span && span.parentNode) { |
| // one entry (#F6C500, a saturated gold) wasn't actually light at
| | var p = span.parentNode; |
| // all, which is very likely what read as "really dark" in practice. | | while (span.firstChild) p.insertBefore(span.firstChild, span); |
| // Replaced with 10 colors in the site's own warm palette (matching
| | p.removeChild(span); |
| // the maroon/cream tones already used throughout Common.css -
| |
| // #b5451b, #fdf0eb, #f0cfc4), all kept in the ~94-97% lightness
| |
| // range so they stay easy to read against dark text regardless of | |
| // which one a given teeka lands on. First entry is #fcf5f3 - the
| |
| // exact background .teeka-block itself already uses on a native
| |
| // teeka page (verified at 97.1% lightness), per direction, so the
| |
| // most common single-commentary case matches the site's own
| |
| // existing look exactly rather than introducing a new shade for it.
| |
| var TEEKA_COLOR_PALETTE = [
| |
| '#fcf5f3', '#F5EFE6', '#F0F5EC', '#EEF3F6', '#F6EFF3',
| |
| '#F3F0F8', '#F8F3EA', '#EEF6F2', '#F6EEEE', '#F2F0EC'
| |
| ];
| |
| // Stable djb2-style hash - same teeka slug always maps to the SAME
| |
| // color everywhere on the site (not re-randomized per page load or | |
| // fetch order) - verified directly: a reader learning "this shade =
| |
| // भावप्रकाशः" needs that to hold site-wide.
| |
| function teekaColorForSlug(slug) {
| |
| var h = 5381;
| |
| for (var i = 0; i < slug.length; i++) h = ((h * 33) ^ slug.charCodeAt(i)) >>> 0;
| |
| return TEEKA_COLOR_PALETTE[h % TEEKA_COLOR_PALETTE.length];
| |
| } | | } |
| | Store.cache(); Store.remove(id); renderNoteCards(); updateBadge(); |
| | } |
|
| |
|
| function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
| | function renderBookmarkCards() { |
| function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } } | | if (!_bookmarks.length) { |
| | | $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>'); |
| function fetchAllSubpages(rootTitle) {
| | return; |
| var deferred = $.Deferred(), subpages = [], nsNum = mw.config.get('wgNamespaceNumber'), localName = mw.config.get('wgTitle');
| |
| function ask(apfrom) {
| |
| var params = { action: 'query', list: 'allpages', apprefix: localName + '/', apnamespace: nsNum, aplimit: 'max', format: 'json' };
| |
| if (apfrom) params.apcontinue = apfrom;
| |
| $.ajax({ url: mw.util.wikiScript('api'), data: params, dataType: 'json' }).then(function (data) {
| |
| var pages = (data.query && data.query.allpages) || [];
| |
| pages.forEach(function (p) { subpages.push(p.title); });
| |
| if (data.continue && data.continue.apcontinue) ask(data.continue.apcontinue);
| |
| else deferred.resolve(subpages);
| |
| }).fail(function () { deferred.resolve(subpages); });
| |
| }
| |
| ask(null);
| |
| return deferred.promise();
| |
| } | | } |
| | var html = ''; |
| | _bookmarks.slice().reverse().forEach(function(b){ |
| | html += '<div class="gra-bookmark-card" data-gra-id="'+esc(b.id)+'">' |
| | + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>' |
| | + '<div class="gra-bookmark-info">' |
| | + '<div class="gra-bookmark-name">'+esc(b.name)+'</div>' |
| | + (b.quote ? '<div class="gra-bookmark-quote">'+esc(b.quote)+'</div>' : '') |
| | + '</div>' |
| | + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>' |
| | + '</div>'; |
| | }); |
| | $paneBookmarks.html(html); |
| | } |
|
| |
|
| function fetchTeekaJson(title) {
| | // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal |
| return $.ajax({
| | // localStorage annotations, they're real saved page content (QuickEdit |
| url: mw.util.wikiScript('api'),
| | // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved |
| data: { action: 'query', titles: 'JSON:' + title, prop: 'revisions', rvslots: 'main', rvprop: 'content', format: 'json', formatversion: 2 },
| | // HTML). So this reads straight off the live DOM on every open, no |
| dataType: 'json'
| | // fetch, no storage — verified the scan + both-direction lookup logic |
| }).then(function (data) {
| | // against realistic saved-footnote HTML before wiring this in (6/6 pass). |
| var pages = (data.query && data.query.pages) || [];
| | function renderFootnoteCards() { |
| var page = pages[0];
| | var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item'); |
| if (!page || page.missing) return null;
| | if (!items.length) { |
| var content = page.revisions && page.revisions[0] && page.revisions[0].slots.main.content;
| | $paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>'); |
| if (!content) return null;
| | return; |
| try { var parsed = JSON.parse(content); parsed._title = title; return parsed; }
| |
| catch (e) { return null; }
| |
| }).catch(function () { return null; });
| |
| } | | } |
| | var html = ''; |
| | Array.prototype.forEach.call(items, function (li) { |
| | var numEl = li.querySelector('.gra-qe-footnote-num'); |
| | var quoteEl = li.querySelector('.gra-qe-footnote-quote'); |
| | var textEl = li.querySelector('.gra-qe-footnote-text'); |
| | var num = numEl ? numEl.textContent.trim() : ''; |
| | // CHANGED: was showing the NUMBER as the card's primary line and the |
| | // footnote's own note text as secondary — nowhere did it show WHICH |
| | // text in the document the footnote actually belongs to, which was |
| | // the whole point of being able to identify one footnote from |
| | // another. Now shows the referenced quote as primary (matching how |
| | // Notes/Bookmarks already identify themselves by quote, not by an |
| | // arbitrary id), with the number as a small prefix and the |
| | // footnote's own note text as secondary. Falls back to the note |
| | // text alone if a footnote predates this change and has no quote |
| | // span at all, rather than showing nothing. |
| | var quote = quoteEl ? quoteEl.textContent : ''; |
| | var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent); |
| | var primary = quote ? (num + ' ' + quote) : (num + ' ' + text); |
| | var id = li.getAttribute('data-gra-id') || ''; |
| | html += '<div class="gra-bookmark-card gra-footnote-card" data-gra-id="'+esc(id)+'">' |
| | + '<span class="gra-icon gra-icon-footnote" aria-hidden="true"></span>' |
| | + '<div class="gra-bookmark-info">' |
| | + '<div class="gra-bookmark-name">'+esc(primary)+'</div>' |
| | + (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '') |
| | + '</div>' |
| | + '</div>'; |
| | }); |
| | $paneFootnotes.html(html); |
| | } |
|
| |
|
| // Finds this document's commentary subpages - filters to the site's | | function scrollToHighlight(id) { |
| // own established commentary-path convention (Vyakhya/Bhashya are | | var el = document.querySelector('[data-gra-id="'+id+'"]'); |
| // shown; Moola/Ullekha are never commentary content). | | if (!el) return; |
| function findCommentarySubpages(rootTitle) {
| | el.scrollIntoView({behavior:'smooth', block:'center'}); |
| return $.Deferred(function (d) {
| | el.classList.add('gra-hl-active'); |
| fetchAllSubpages(rootTitle).then(function (all) {
| | setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000); |
| d.resolve(all.filter(function (t) {
| | } |
| return t !== rootTitle && (/\/vyakhya(\/|$)/i.test(t) || /\/bhashya(\/|$)/i.test(t));
| |
| }));
| |
| });
| |
| }).promise();
| |
| }
| |
|
| |
|
| // Builds moola-verse-id -> [{teekaTitle, teekaSlug, text, blockId}].
| | function wireEvents() { |
| // Handles TWO possible parent-linking shapes so this works across
| |
| // every commentary document on the site, not just ones from any one
| |
| // generation pipeline: (1) DIRECT - a type:"teeka" block's own
| |
| // "parent" already IS the moola verse id; (2) TWO-HOP - it points to
| |
| // a type:"verse" block WITHIN THE SAME teeka doc (a quoted-verse
| |
| // reference), and THAT block's own parent is the moola verse id.
| |
| // Deliberately skips anything whose parent is "UNVERIFIED" or
| |
| // missing, rather than guessing.
| |
| // CHANGED: also builds a slug -> color-palette-index map here, based
| |
| // on teekaDocs' OWN ORDER (the order commentaries were actually
| |
| // discovered/fetched for this page) rather than hashing each slug
| |
| // independently. Confirmed directly this was the actual issue: hash-
| |
| // based assignment gives every teeka a color, but nothing guarantees
| |
| // the FIRST one shown on a page lands on palette slot 0 (#fcf5f3,
| |
| // the site's own real .teeka-block default) - it gets whatever slot
| |
| // its hash happens to produce. Positional assignment makes "first
| |
| // shown = the exact default color" a guarantee, not a coincidence,
| |
| // at the cost of a specific teeka's color now depending on where it
| |
| // appears in a given page's own list rather than being fixed
| |
| // site-wide - a reasonable trade given the direction received.
| |
| var teekaColorIndexBySlug = {};
| |
| function buildTeekaInMoolaIndex(teekaDocs) {
| |
| var index = {};
| |
| teekaColorIndexBySlug = {};
| |
| var colorIdx = 0;
| |
| teekaDocs.forEach(function (td) {
| |
| if (!td || !td.blocks || !td.doc) return;
| |
| var slug = td.doc.slug || td._title;
| |
| if (!(slug in teekaColorIndexBySlug)) {
| |
| teekaColorIndexBySlug[slug] = colorIdx % TEEKA_COLOR_PALETTE.length;
| |
| colorIdx++;
| |
| }
| |
| var blockById = {};
| |
| td.blocks.forEach(function (b) { blockById[b.id] = b; });
| |
| td.blocks.forEach(function (b) {
| |
| if (b.type !== 'teeka') return;
| |
| var moolaId = null;
| |
| if (b.parent && b.parent !== 'UNVERIFIED' && !blockById[b.parent]) {
| |
| moolaId = b.parent;
| |
| } else {
| |
| var verseRef = blockById[b.parent];
| |
| if (verseRef && (verseRef.type === 'verse' || verseRef.type === 'verse-ref') && verseRef.parent && verseRef.parent !== 'UNVERIFIED') {
| |
| moolaId = verseRef.parent;
| |
| }
| |
| }
| |
| if (!moolaId) return;
| |
| if (!index[moolaId]) index[moolaId] = [];
| |
| index[moolaId].push({
| |
| teekaTitle: td.doc.title || td._title,
| |
| teekaSlug: slug,
| |
| text: (b.lines || []).join(' '),
| |
| blockId: b.id
| |
| });
| |
| });
| |
| });
| |
| return index;
| |
| }
| |
|
| |
|
| function injectTeekaInMoola(index) { | | /* Suppress native context menu inside article content (Android/desktop) */ |
| var injectedAny = false;
| | document.addEventListener('contextmenu', function(e) { |
| Object.keys(index).forEach(function (verseId) {
| | var tag = e.target.tagName; |
| var $verseEl = $(document.getElementById(verseId));
| | if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; |
| if (!$verseEl.length) return;
| | var c = document.querySelector(CONTENT_SEL); |
| var entries = index[verseId];
| | if (c && c.contains(e.target)) e.preventDefault(); |
| var $wrap = $('<div>').addClass('gr-injected-teeka-group');
| | }, { passive: false }); |
| entries.forEach(function (entry) {
| |
| // CHANGED: was teekaColorForSlug(slug) (hash-based) -
| |
| // now looks up the position assigned in
| |
| // buildTeekaInMoolaIndex, falling back to the hash only
| |
| // if this slug is somehow missing from that map (should
| |
| // not normally happen, but safer than an undefined color).
| |
| var colorIdx = teekaColorIndexBySlug[entry.teekaSlug];
| |
| var color = (colorIdx !== undefined) ? TEEKA_COLOR_PALETTE[colorIdx] : teekaColorForSlug(entry.teekaSlug);
| |
| var $entry = $('<div>').addClass('teeka-block')
| |
| .attr('data-teeka-slug', entry.teekaSlug)
| |
| .attr('data-teeka-block-id', entry.blockId)
| |
| .css('background', color);
| |
| $('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry);
| |
| $('<div>').addClass('teeka-body').html(entry.text).appendTo($entry);
| |
| $wrap.append($entry);
| |
| });
| |
| $verseEl.after($wrap);
| |
| // Tell the script switcher about the new text, so an interleaved
| |
| // commentary is transliterated like the rest of the page instead
| |
| // of staying in Devanagari.
| |
| try {
| |
| window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: $wrap[0] } }));
| |
| } catch (e) { }
| |
| injectedAny = true;
| |
| });
| |
| return injectedAny;
| |
| }
| |
| function removeTeekaInMoola() { $('.gr-injected-teeka-group').remove(); }
| |
|
| |
|
| // CRITICAL FIX: confirmed directly this feature was corrupting saved | | /* Desktop mouseup */ |
| // page content. QuickEdit is a contenteditable-based editor operating | | $(document).on('mouseup', function(e){ |
| // on the page's LIVE DOM - since this feature injects real elements
| | if (e.button !== 0) return; |
| // (interleaved teeka blocks, inline checkboxes wrapping links) into
| | if (_mobile) return; |
| // that same DOM, QuickEdit's save/serialization pipeline had no way
| | setTimeout(tryShowActions, 20); |
| // to distinguish them from genuine content, and was saving them back
| | }); |
| // into the page's actual wikitext as literal escaped HTML text. This
| |
| // teardown strips every trace of this feature's DOM changes -
| |
| // removing injected teeka blocks entirely, and for inline checkboxes,
| |
| // specifically UNWRAPPING each link back to its own original
| |
| // position (not just deleting the wrapper, which would also delete
| |
| // the link itself) - restoring the DOM to exactly what the page's
| |
| // real, saved content actually is, before any editor can see it.
| |
| function teardownTeekaInterleave() {
| |
| removeTeekaInMoola();
| |
| $('.gr-teeka-cb-wrap').each(function () {
| |
| var $wrap = $(this);
| |
| var $link = $wrap.find('a');
| |
| if ($link.length) $wrap.before($link);
| |
| $wrap.remove();
| |
| });
| |
| // CHANGED: also strips the gr-teeka-list-item class added to any
| |
| // <li> for bullet-removal - confirmed via direct test this was
| |
| // missed initially, which would have left a class attribute
| |
| // change in the saved content even after the wrapper/checkbox
| |
| // itself was correctly removed. Also removes the class
| |
| // ATTRIBUTE entirely (not just the class name) once empty -
| |
| // classList.remove() alone leaves class="" behind, which a
| |
| // byte-for-byte test caught as still not matching the true
| |
| // original markup (which never had a class attribute at all).
| |
| $('.gr-teeka-list-item').each(function () {
| |
| this.classList.remove('gr-teeka-list-item');
| |
| if (!this.className) this.removeAttribute('class');
| |
| });
| |
| }
| |
|
| |
|
| // Watches for the exact DOM signal that editing has actually begun - | | /* Separate timers so mobile + desktop never clobber each other */ |
| // Common.css targets .mw-body-content[contenteditable="true"] | | var _selTimer = null; /* desktop debounce */ |
| // directly, confirming this is the real, load-order-independent
| | var _mobShowTimer = null; /* mobile show-on-touchend */ |
| // trigger QuickEdit itself relies on, rather than trying to
| |
| // intercept every possible UI path that could invoke it (edit
| |
| // button, keyboard shortcut, or anything else) individually and
| |
| // risk missing one.
| |
| function watchForEditMode() { | |
| var target = document.querySelector('.mw-body-content') || document.body;
| |
| if (!target || !window.MutationObserver) return;
| |
| var wasEditing = false;
| |
| var obs = new MutationObserver(function (mutations) {
| |
| mutations.forEach(function (m) {
| |
| if (m.attributeName !== 'contenteditable') return;
| |
| var isEditingNow = m.target.getAttribute('contenteditable') === 'true';
| |
| if (isEditingNow && !wasEditing) {
| |
| teardownTeekaInterleave();
| |
| wasEditing = true;
| |
| } else if (!isEditingNow && wasEditing) {
| |
| // Editing ended without a page reload (e.g. cancelled) -
| |
| // restore using the already-fetched data cached in
| |
| // memory, no need to re-fetch anything from the server.
| |
| if (teekaIndex) renderInterleave();
| |
| if (lastAugmentArgs) {
| |
| var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
| |
| if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
| |
| }
| |
| wasEditing = false;
| |
| }
| |
| });
| |
| });
| |
| obs.observe(target, { attributes: true, attributeFilter: ['contenteditable'], subtree: true });
| |
| }
| |
|
| |
|
| function normalizeWikiPath(p) { | | /* Mobile: show fab quickly after finger lifts (selection settled). |
| try { p = decodeURIComponent(p); } catch (e) { }
| | 180ms feels instant while still letting the range stabilise. */ |
| return p.replace(/_/g, ' ').replace(/\/+$/, ''); | | document.addEventListener('touchend', function(e) { |
| } | | if (!_mobile) return; |
| | if ($fab[0] && $fab[0].contains(e.target)) return; |
| | clearTimeout(_mobShowTimer); |
| | _mobShowTimer = setTimeout(function() { |
| | var sel = window.getSelection(); |
| | if (!sel || sel.isCollapsed || !sel.toString().trim()) return; |
| | tryShowActions(); |
| | }, 180); |
| | }, { passive: true }); |
|
| |
|
| // ── State ────────────────────────────────────────────────────── | | /* Mobile: only HIDE the fab when selection is cleared while it's visible. |
| var selectedTeekaSlugs = {}; | | (Reposition isn't needed now that the bar is docked, and re-running |
| try { JSON.parse(readLS(LS_TEEKA_KEY) || '[]').forEach(function (s) { selectedTeekaSlugs[s] = true; }); } catch (e) { }
| | showFab here was causing the lag/flicker.) */ |
| var teekaIndex = null;
| | document.addEventListener('selectionchange', function() { |
| var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
| | if (!_mobile) return; |
| var lastAugmentArgs = null; // {titles, docs} - cached to restore inline checkboxes after a cancelled edit without re-fetching
| | if (!$fab.hasClass('gra-fab-visible')) return; |
| var checkboxesBySlug = {};
| | var sel = window.getSelection(); |
| var readyCallbacks = [];
| | if (!sel || sel.isCollapsed || !sel.toString().trim()) { |
| var changeCallbacks = [];
| | clearTimeout(_mobShowTimer); |
| var visibilityCallbacks = []; | | hideActions(); |
| var isReady = false;
| | } |
| var inlineListVisible = null; // null = no inline checkboxes exist at all on this page
| | }); |
|
| |
|
| function registerCheckbox(slug, $cb) { (checkboxesBySlug[slug] = checkboxesBySlug[slug] || []).push($cb); } | | /* selectionchange debounced (desktop only) */ |
| | document.addEventListener('selectionchange', function() { |
| | if (_mobile) return; |
| | _selVersion++; |
| | clearTimeout(_selTimer); |
| | var v = _selVersion; |
| | _selTimer = setTimeout(function(){ |
| | if (v !== _selVersion) return; |
| | if (_fabSelVer === v) return; |
| | tryShowActions(); |
| | }, 600); |
| | }); |
|
| |
|
| function renderInterleave() { | | /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */ |
| removeTeekaInMoola();
| | $fab[0].addEventListener('touchstart', function(e) { |
| if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return;
| | _fabTouched = true; |
| var filtered = {};
| | /* Don't propagate to document handler */ |
| Object.keys(teekaIndex).forEach(function (verseId) {
| | e.stopPropagation(); |
| var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; });
| | }, { passive: true }); |
| if (entries.length) filtered[verseId] = entries;
| |
| });
| |
| var injectedAny = injectTeekaInMoola(filtered);
| |
| if (!injectedAny) console.warn('[grTeekaInterleave] Selected commentaries loaded, but none of their verse ids matched any verse-block on THIS page.');
| |
| }
| |
|
| |
|
| function toggleSlug(slug, checked) { | | /* Click outside → hide actions (blocked if fab was touched) */ |
| if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug];
| | $(document).on('mousedown touchstart', function(e){ |
| writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs)));
| | if (_fabTouched) { _fabTouched = false; return; } |
| (checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); });
| | var t = e.target; |
| renderInterleave();
| | if ($fab[0] && $fab[0].contains(t)) return; |
| changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); });
| | if ($fbComposer[0] && $fbComposer[0].contains(t)) return; |
| }
| | if ($ntComposer[0] && $ntComposer[0].contains(t)) return; |
| | | if ($bmComposer[0] && $bmComposer[0].contains(t)) return; |
| // Augments existing <a> links pointing to a known commentary subpage
| | hideActions(); |
| // with an inline checkbox, wherever the link already appears -
| | }); |
| // matched by real href rather than any assumed list markup, since
| |
| // that markup isn't something this script controls.
| |
| // CHANGED: wraps the checkbox AND the link together in one flex
| |
| // container, rather than just wrapping the checkbox alone next to
| |
| // an untouched sibling link - confirmed directly (screenshot, twice
| |
| // now) that aligning a wrapped checkbox against ambient text-
| |
| // baseline metrics alone still leaves a small vertical offset,
| |
| // especially with the site's custom Devanagari font's own metrics.
| |
| // Flexbox align-items:center between the checkbox and the link's
| |
| // own line box is a more reliable way to center them against EACH
| |
| // OTHER directly, rather than each independently against the
| |
| // surrounding line. The link is moved (not cloned) into the
| |
| // wrapper at its original DOM position, so its href/click behavior
| |
| // and the surrounding "\u2014 author" text's sibling order are both
| |
| // preserved exactly as before.
| |
| function augmentExistingTeekaLinks(subpageTitles, docs) {
| |
| var slugByPath = {};
| |
| subpageTitles.forEach(function (title, i) {
| |
| var doc = docs[i];
| |
| if (!doc) return;
| |
| slugByPath[normalizeWikiPath(mw.util.getUrl(title))] = doc.doc.slug || doc._title;
| |
| });
| |
| var $contentLinks = $('#mw-content-text a[href], .mw-parser-output a[href]');
| |
| var augmented = [];
| |
| $contentLinks.each(function () {
| |
| var href = this.getAttribute('href') || '';
| |
| var path;
| |
| try { path = normalizeWikiPath(new URL(href, window.location.href).pathname); }
| |
| catch (e) { return; }
| |
| var slug = slugByPath[path];
| |
| if (!slug || this.closest('.gr-injected-teeka-group')) return;
| |
| var li = this.closest('li');
| |
| if (li) li.classList.add('gr-teeka-list-item');
| |
| var $anchor = $(this);
| |
| var $wrap = $('<span>').addClass('gr-teeka-cb-wrap');
| |
| var $cb = $('<input>').attr({ type: 'checkbox' }).addClass('gr-teeka-inline-checkbox').prop('checked', !!selectedTeekaSlugs[slug]);
| |
| $anchor.before($wrap);
| |
| $wrap.append($cb);
| |
| $wrap.append($anchor);
| |
| registerCheckbox(slug, $cb);
| |
| $cb.on('change', function () { toggleSlug(slug, $cb.is(':checked')); });
| |
| augmented.push({ slug: slug, el: $cb[0] });
| |
| });
| |
| var missedTitles = subpageTitles.filter(function (t, i) {
| |
| var d = docs[i]; if (!d) return false;
| |
| var s = d.doc.slug || d._title;
| |
| return !augmented.some(function (a) { return a.slug === s; });
| |
| });
| |
| if (missedTitles.length) console.warn('[grTeekaInterleave] No matching link found on this page for: ' + missedTitles.join(', ') + ' - the toolbar dropdown still covers them.');
| |
| return augmented;
| |
| }
| |
|
| |
|
| // Tracks whether ANY inline checkbox is currently on-screen, and | | /* ── FAB buttons — use touchend for mobile, click for desktop ── */ |
| // notifies subscribers only when that overall state actually flips -
| | function fabAction(btnId, action) { |
| // verified in isolation before wiring in here. Drives whether | | var el = document.getElementById(btnId); |
| // readerToolbar.js's "Commentaries" control needs to be shown as a
| | if (!el) return; |
| // fallback (inline list scrolled out of view) or can stay hidden
| | /* touchend: fires before document touchstart clears _selRange */ |
| // (reader can already see and use the inline checkboxes).
| | el.addEventListener('touchend', function(e) { |
| function observeInlineVisibility(checkboxEls) {
| | e.preventDefault(); |
| if (!checkboxEls.length || !window.IntersectionObserver) return;
| | e.stopPropagation(); |
| var visibleSet = new Set();
| | if (!_selRange && !reCaptureFromDOM()) return; |
| var obs = new IntersectionObserver(function (entries) {
| | action(); |
| entries.forEach(function (entry) {
| | }, { passive: false }); |
| var id = entry.target.__grTeekaId;
| | /* click: for desktop */ |
| if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id);
| | el.addEventListener('click', function(e) { |
| });
| | e.preventDefault(); |
| var anyVisible = visibleSet.size > 0;
| | e.stopPropagation(); |
| if (anyVisible !== inlineListVisible) {
| | if (!_selRange && !reCaptureFromDOM()) return; |
| inlineListVisible = anyVisible;
| | action(); |
| visibilityCallbacks.forEach(function (cb) { cb(anyVisible); });
| | }); |
| }
| |
| }, { rootMargin: '-60px 0px 0px 0px', threshold: 0 }); | |
| checkboxEls.forEach(function (el, i) { el.__grTeekaId = 'cb' + i; obs.observe(el); }); | |
| } | | } |
|
| |
|
| function init() { | | fabAction('gra-fab-note', openNoteComposer); |
| // Runs unconditionally, before any of the early-return checks
| | fabAction('gra-fab-bookmark', openBookmarkComposer); |
| // below - harmless as a no-op teardown on pages where nothing
| | fabAction('gra-fab-feedback', openFeedbackComposer); |
| // ever gets injected, but critical to have active on every page
| |
| // this feature COULD have injected into, regardless of which
| |
| // exact code path led here.
| |
| watchForEditMode();
| |
| var ns = mw.config.get('wgNamespaceNumber');
| |
| var pageName = mw.config.get('wgPageName') || '';
| |
| var isMainPg = (pageName === 'Main_Page' || pageName === 'Main Page');
| |
| var isTeekaPage = !!document.querySelector('.gr-teeka-page[data-interleaved="1"], .gr-parent-ref-block');
| |
| if (ns !== 0 || isMainPg || isTeekaPage) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
| |
|
| |
|
| var rootTitle = mw.config.get('wgTitle');
| | document.getElementById('gra-fab-search').addEventListener('touchend', function(e) { |
| findCommentarySubpages(rootTitle).then(function (subpages) {
| | e.preventDefault(); e.stopPropagation(); |
| if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
| | var q = _selText; |
| $.when.apply($, subpages.map(fetchTeekaJson)).then(function () {
| | hideActions(); |
| var fetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
| | _selRange = null; _selText = ''; _selRect = null; |
| // Pair title+doc together FIRST, then filter as one unit -
| | if (q && window.showSearchDialog) { window.showSearchDialog(q); } |
| // filtering `docs` separately from `subpages` and passing
| | }, { passive: false }); |
| // both (now misaligned) arrays elsewhere was a confirmed
| | document.getElementById('gra-fab-search').addEventListener('click', function(e) { |
| // real bug in an earlier version of this feature.
| | e.preventDefault(); e.stopPropagation(); |
| var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; })
| | var q = _selText; |
| .filter(function (p) {
| | hideActions(); |
| if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title);
| | _selRange = null; _selText = ''; _selRect = null; |
| return !!p.doc;
| | if (q && window.showSearchDialog) { window.showSearchDialog(q); } |
| });
| | else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); } |
| var topDocs = pairs.map(function (p) { return p.doc; });
| | }); |
| | |
| // NEW: sub-commentaries (tippanis) - a teeka doc declares
| |
| // its own children via doc.related (an array of slugs,
| |
| // matching the same field this session's own generator
| |
| // already writes into new JSON docs). Only the ASK here
| |
| // is to show these as sub-categories in the options list
| |
| // and gate their visibility on the parent being checked
| |
| // - so this only needs one more level of fetching, not
| |
| // unbounded recursion.
| |
| var relatedSlugs = [];
| |
| topDocs.forEach(function (d) { (d.doc.related || []).forEach(function (s) { relatedSlugs.push(s); }); });
| |
| var uniqueRelated = relatedSlugs.filter(function (s, i) { return relatedSlugs.indexOf(s) === i; });
| |
| | |
| function finishInit(subDocsBySlug) {
| |
| var allDocs = topDocs.slice();
| |
| Object.keys(subDocsBySlug).forEach(function (slug) { if (subDocsBySlug[slug]) allDocs.push(subDocsBySlug[slug]); });
| |
| teekaIndex = buildTeekaInMoolaIndex(allDocs);
| |
| availableList = topDocs.map(function (d) {
| |
| var subItems = (d.doc.related || [])
| |
| .map(function (s) { return subDocsBySlug[s]; })
| |
| .filter(Boolean)
| |
| .map(function (sd) { return { slug: sd.doc.slug || sd._title, title: sd.doc.title || sd._title }; });
| |
| return { slug: d.doc.slug || d._title, title: d.doc.title || d._title, subItems: subItems };
| |
| });
| |
| // Cached at module level so watchForEditMode can
| |
| // correctly re-run link augmentation after a
| |
| // cancelled edit, without needing to re-fetch
| |
| // anything from the server.
| |
| lastAugmentArgs = { titles: pairs.map(function (p) { return p.title; }), docs: topDocs };
| |
| var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
| |
| if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
| |
| else inlineListVisible = false; // no inline checkboxes exist - toolbar control should always show as the only access point
| |
| if (Object.keys(selectedTeekaSlugs).length) renderInterleave();
| |
| isReady = true;
| |
| readyCallbacks.forEach(function (cb) { cb(availableList); });
| |
| if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); });
| |
| }
| |
| | |
| if (!uniqueRelated.length) { finishInit({}); return; }
| |
| $.when.apply($, uniqueRelated.map(fetchTeekaJson)).then(function () {
| |
| var subFetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
| |
| var subDocsBySlug = {};
| |
| uniqueRelated.forEach(function (slug, i) {
| |
| if (!subFetched[i]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
| |
| subDocsBySlug[slug] = subFetched[i];
| |
| });
| |
| finishInit(subDocsBySlug);
| |
| });
| |
| });
| |
| });
| |
| }
| |
| | |
| window.grTeekaInterleave = {
| |
| teardown: teardownTeekaInterleave,
| |
| onReady: function (cb) { if (isReady) cb(availableList); else readyCallbacks.push(cb); },
| |
| onChange: function (cb) { changeCallbacks.push(cb); },
| |
| onVisibilityChange: function (cb) { visibilityCallbacks.push(cb); if (inlineListVisible !== null) cb(inlineListVisible); },
| |
| getSelected: function () { return Object.keys(selectedTeekaSlugs); },
| |
| toggle: function (slug, checked) { toggleSlug(slug, checked); },
| |
| registerCheckbox: registerCheckbox
| |
| };
| |
| | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); | |
| else init();
| |
| | |
| }());
| |
| | |
| | |
| /* ═══════════════════════════════════════════════════════════════
| |
| Devanagari page titles, site-wide (window.grTitles)
| |
| ───────────────────────────────────────────────────────────────
| |
| One source of truth for "what is this page called": the Main Page
| |
| catalogue, where every published work and commentary is listed as
| |
| [[Slug|देवनागरी नाम]]. update_main_page.py keeps that list current,
| |
| so a newly pushed commentary gets its Devanagari name everywhere
| |
| without anyone editing a hard-coded list.
| |
| | |
| Used by:
| |
| • readerToolbar.js search results (grDocDisplayTitle & co. ask
| |
| grTitles first, then fall back to their own GR_DOC_TITLES map)
| |
| • the Updates page
| |
| • this block itself, which relabels any link on the page whose
| |
| text is still the English slug (e.g. the "Vadavali · Bhavavivaranam"
| |
| page-nav line), the mobile page heading, and the browser tab title.
| |
| | |
| The catalogue is cached in localStorage for CACHE_HOURS and refreshed
| |
| in the background, so lookups are synchronous on almost every view.
| |
| ═══════════════════════════════════════════════════════════════ */
| |
| (function () {
| |
| 'use strict';
| |
| | |
| var CATALOGUE_PAGE = 'Main_Page';
| |
| var CACHE_KEY = 'gr_titles_v1';
| |
| var CACHE_HOURS = 1;
| |
| var DEVA = /[ऀ-ॿ]/;
| |
| | |
| var full = {}; // "Katha/Vyakhya/VyasatirthaVivarana" → "श्रीव्यासतीर्थविरचितविवरणम्"
| |
| var leaf = {}; // "VyasatirthaVivarana" → same; null when two pages share a leaf
| |
| var waiting = [];
| |
| var loaded = false;
| |
| | |
| function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
| |
| function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
| |
| function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
| |
| function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } } | |
| function script(name) {
| |
| if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
| |
| return (cfg('wgScriptPath') || '') + '/' + name + '.php';
| |
| }
| |
|
| |
|
| function add(title, label, override) { | | /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */ |
| title = norm(title); label = String(label || '').replace(/<[^>]*>/g, '').trim();
| | (function () { |
| if (!title || !label || !DEVA.test(label)) return;
| | var dismissEl = document.getElementById('gra-fab-dismiss'); |
| if (override || !full[title]) full[title] = label;
| | if (!dismissEl) return; |
| var l = title.split('/').pop(); | | function doDismiss(e) { |
| if (!(l in leaf) || override) leaf[l] = label; | | e.preventDefault(); e.stopPropagation(); |
| else if (leaf[l] !== label) leaf[l] = null; // ambiguous leaf: don't guess | | hideActions(); |
| }
| | _selRange = null; _selText = ''; _selRect = null; |
| function parseCatalogue(txt) {
| | if (window.getSelection) { |
| var out = {}, re = /\[\[([^\]|#]+)\|([^\]]+)\]\]/g, m;
| | var s = window.getSelection(); |
| while ((m = re.exec(txt || ''))) { | | if (s && s.removeAllRanges) s.removeAllRanges(); |
| var k = norm(m[1]);
| |
| if (!out[k]) out[k] = m[2].replace(/<[^>]*>/g, '').trim();
| |
| } | | } |
| return out;
| | } |
| }
| | dismissEl.addEventListener('touchend', doDismiss, { passive: false }); |
| function useCatalogue(map) {
| | dismissEl.addEventListener('click', doDismiss); |
| full = {}; leaf = {};
| | }()); |
| Object.keys(map).forEach(function (k) { add(k, map[k]); });
| |
| addCurrentPage();
| |
| }
| |
| // The page being viewed names itself in .gr-doc-title — covers a page
| |
| // that isn't on the Main Page yet.
| |
| function addCurrentPage() {
| |
| var el = document.querySelector('#mw-content-text .gr-doc-title');
| |
| var me = norm(cfg('wgPageName'));
| |
| if (el && me && !full[me]) add(me, el.textContent);
| |
| }
| |
|
| |
|
| // ── public API ──────────────────────────────────────────────── | | /* Feedback composer */ |
| function get(title) { return full[norm(title)] || null; } | | $fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); }); |
| function getLeaf(seg) { var v = leaf[norm(seg)]; return v || null; }
| | $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer); |
| function whenReady(cb) { if (loaded) cb(); else waiting.push(cb); } | | $fbSubmit.on('click', submitFeedback); |
| function all() { var o = {}; for (var k in full) o[k] = full[k]; return o; } | | $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); }); |
| window.grTitles = { get: get, leaf: getLeaf, all: all, whenReady: whenReady, parse: parseCatalogue };
| |
|
| |
|
| function finish() { | | /* Note composer */ |
| loaded = true;
| | $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); }); |
| var cbs = waiting; waiting = [];
| | $('#gra-nt-cancel').on('click', closeNoteComposer); |
| cbs.forEach(function (cb) { try { cb(); } catch (e) { } });
| | $ntSubmit.on('click', submitNote); |
| relabel();
| | $ntInput.on('keydown', function(e){ |
| if (window.mw && mw.hook) mw.hook('gr.titles').fire(window.grTitles);
| | if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote(); |
| } | | if (e.key==='Escape') closeNoteComposer(); |
| | }); |
|
| |
|
| // ── relabel what's on screen ────────────────────────────────── | | /* Bookmark composer */ |
| function articleTitleFromHref(href) { | | $('#gra-bm-cancel').on('click', closeBookmarkComposer); |
| var u;
| | $bmSubmit.on('click', submitBookmark); |
| try { u = new URL(href, location.href); } catch (e) { return null; }
| | $bmInput.on('keydown', function(e){ |
| if (u.origin !== location.origin) return null;
| | if (e.key==='Enter') submitBookmark(); |
| var t = u.searchParams.get('title');
| | if (e.key==='Escape') closeBookmarkComposer(); |
| if (!t) {
| | }); |
| var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0];
| |
| if (u.pathname.indexOf(ap) !== 0) return null;
| |
| t = u.pathname.slice(ap.length);
| |
| }
| |
| try { t = decodeURIComponent(t); } catch (e) { }
| |
| return norm(t);
| |
| } | |
| function relabel(root) {
| |
| root = root || document;
| |
| var links = root.querySelectorAll('#mw-content-text a[href], .gr-sticky-title a[href], #mw-panel a[href], .vector-toc a[href]');
| |
| Array.prototype.forEach.call(links, function (a) {
| |
| if (a.children.length) return; // leave rich links alone
| |
| var text = norm(a.textContent);
| |
| if (!text || DEVA.test(text)) return; // already Devanagari
| |
| var target = articleTitleFromHref(a.getAttribute('href')) || norm(a.getAttribute('title'));
| |
| if (!target) return;
| |
| var tail = target.split('/').pop();
| |
| if (text !== target && text !== tail) return; // only replace a bare slug
| |
| var label = get(target) || (text === tail ? getLeaf(tail) : null);
| |
| if (label) a.textContent = label;
| |
| });
| |
| // Page heading (shown on mobile) and the browser tab
| |
| var me = norm(cfg('wgPageName'));
| |
| var myLabel = get(me);
| |
| if (!myLabel) return;
| |
| var h = document.querySelector('#firstHeading .mw-page-title-main') || document.getElementById('firstHeading');
| |
| if (h && !h.children.length && norm(h.textContent) === me) h.textContent = myLabel;
| |
| var wgTitle = norm(cfg('wgTitle'));
| |
| if (wgTitle && document.title.indexOf(wgTitle) === 0) document.title = myLabel + document.title.slice(wgTitle.length);
| |
| else if (document.title.indexOf(me) === 0) document.title = myLabel + document.title.slice(me.length);
| |
| }
| |
|
| |
|
| // ── load ───────────────────────────────────────────────────── | | /* Panel */ |
| function fetchFresh() { | | $('#gra-panel-close').on('click', closePanel); |
| return fetch(script('index') + '?title=' + CATALOGUE_PAGE + '&action=raw', { credentials: 'same-origin' })
| | $backdrop.on('click touchend', function(e){ |
| .then(function (r) { return r.ok ? r.text() : Promise.reject(); })
| | e.preventDefault(); |
| .then(function (txt) {
| | if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer(); |
| var map = parseCatalogue(txt);
| | else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer(); |
| lsSet(CACHE_KEY, JSON.stringify({ at: Date.now(), map: map }));
| | else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer(); |
| return map;
| | else closePanel(); |
| });
| |
| } | |
| function boot() {
| |
| var cached = null;
| |
| try { cached = JSON.parse(lsGet(CACHE_KEY) || 'null'); } catch (e) { }
| |
| if (cached && cached.map) { useCatalogue(cached.map); finish(); }
| |
| var stale = !cached || (Date.now() - cached.at > CACHE_HOURS * 3600000);
| |
| if (!stale) return;
| |
| fetchFresh().then(function (map) {
| |
| useCatalogue(map);
| |
| if (!loaded) finish(); else relabel();
| |
| }).catch(function () { if (!loaded) { addCurrentPage(); finish(); } });
| |
| }
| |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
| |
| else boot();
| |
| // Content that arrives later (previews, lazy sections, search panes)
| |
| if (window.mw && mw.hook) mw.hook('wikipage.content').add(function ($c) {
| |
| if (loaded) { addCurrentPage(); relabel($c && $c[0] ? $c[0] : document); }
| |
| }); | | }); |
| }()); | | $tabNotes.on('click', function(){ switchTab('notes'); }); |
| | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); |
| | // NEW: third tab. |
| | $tabFootnotes.on('click', function(){ switchTab('footnotes'); }); |
|
| |
|
| | $paneNotes.on('click', '.gra-note-card', function(e){ |
| | if ($(e.target).hasClass('gra-note-del')) return; |
| | var id = $(this).attr('data-gra-id'); |
| | if (id) { closePanel(); scrollToHighlight(id); } |
| | }); |
| | $paneNotes.on('click', '.gra-note-del', function(e){ |
| | e.stopPropagation(); |
| | var id = $(this).attr('data-del-id'); |
| | if (id) deleteNote(id); |
| | }); |
| | $paneBookmarks.on('click', '.gra-bookmark-card', function(e){ |
| | if ($(e.target).hasClass('gra-bookmark-del')) return; |
| | var id = $(this).attr('data-gra-id'); |
| | if (id) { closePanel(); scrollToHighlight(id); } |
| | }); |
| | $paneBookmarks.on('click', '.gra-bookmark-del', function(e){ |
| | e.stopPropagation(); |
| | var id = $(this).attr('data-del-id'); |
| | if (id) deleteBookmark(id); |
| | }); |
| | // CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's |
| | // redesign — plain highlight span, no href/marker element anymore), so |
| | // this can reuse the EXACT same scrollToHighlight(id) helper Notes/ |
| | // Bookmarks already use below, instead of custom href-parsing logic |
| | // that broke every time the marker format changed. |
| | $paneFootnotes.on('click', '.gra-footnote-card', function(){ |
| | var id = $(this).attr('data-gra-id'); |
| | if (id) { closePanel(); scrollToHighlight(id); } |
| | }); |
|
| |
|
| /* ═══════════════════════════════════════════════════════════════
| | $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){ |
| Header links "Library" and "Updates", and the Updates page
| | var id = $(this).attr('data-gra-id'); |
| ───────────────────────────────────────────────────────────────
| | openPanel('notes'); |
| Desktop (Vector): "Library" and "Updates" added before "Help".
| | setTimeout(function(){ |
| Mobile (Minerva): the same two items in the menu panel, after "Home".
| | var $card = $paneNotes.find('[data-gra-id="'+id+'"]'); |
| "Updates" opens the page My_wiki:Updates, which has two parts:
| | if ($card.length) { |
| | | $card.addClass('gra-card-active'); |
| 1. What's new — filled in automatically from the wiki's edit history
| | $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); |
| into <div id="gr-updates-feed">. Nothing to maintain by hand:
| | setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); |
| • New a page created in the last WINDOW_DAYS days
| |
| • Updated an existing page whose edits in that window add up to
| |
| at least UPDATE_MIN_BYTES (so typo fixes don't show)
| |
| Titles are the Devanagari names from window.grTitles (Main Page catalogue).
| |
| Test pages, helper subpages (/Ullekha, /Moola, /C1 …) and deleted
| |
| pages are left out.
| |
| 2. Announcements & events — a card editors fill by hand with
| |
| {{Bulletin|type=…|date=…|title=…|text=…}} lines on that page.
| |
| | |
| Styling: MediaWiki:Common.css §37 (no inline styles here).
| |
| ═══════════════════════════════════════════════════════════════ */
| |
| (function () {
| |
| 'use strict';
| |
| | |
| var LIBRARY_URL = 'https://dvaita-grantha-hierarchy-library.ai.studio/';
| |
| var LIBRARY_LABEL = 'Library';
| |
| var UPDATES_LABEL = 'Updates';
| |
| var UPDATES_PAGE = 'My_wiki:Updates'; // the page the "Updates" link opens
| |
| var WINDOW_DAYS = 90; // how far back the list looks
| |
| var UPDATE_MIN_BYTES = 1500; // edits smaller than this in total aren't "updates"
| |
| var MAX_ITEMS = 100; // rows shown on the page
| |
| var CACHE_MINUTES = 10; // per-tab cache so every page view isn't an API call
| |
| var CATALOGUE_PAGE = 'Main_Page';
| |
| var SKIP_TITLE = /^(Main Page|Test|Sandbox)|(^|\/)(Ullekha|Moola|C\d+)$|(^|\/)(Test|Teeka|Tippani)\d*$/i;
| |
| var HIDE = []; // exact titles never to list, e.g. ['Sumadhwavijaya/Vyakhya/पदार्थदीपिका']
| |
| | |
| var SEEN_KEY = 'gr_updates_seen';
| |
| var CACHE_KEY = 'gr_updates_cache_v3';
| |
| var isMobile = document.body.classList.contains('skin-minerva');
| |
| | |
| // ── helpers ────────────────────────────────────────────────
| |
| function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
| |
| function wikiHref(title) {
| |
| if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
| |
| var ap = cfg('wgArticlePath') || '/wiki/$1';
| |
| return ap.replace('$1', encodeURIComponent(title.replace(/ /g, '_')).replace(/%2F/g, '/').replace(/%3A/g, ':'));
| |
| }
| |
| function script(name) {
| |
| if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
| |
| return (cfg('wgScriptPath') || '') + '/' + name + '.php';
| |
| }
| |
| function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
| |
| function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
| |
| function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
| |
| function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
| |
| function el(tag, cls, text) {
| |
| var n = document.createElement(tag); | |
| if (cls) n.className = cls;
| |
| if (text != null) n.textContent = text;
| |
| return n;
| |
| }
| |
| function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
| |
| | |
| var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
| |
| function absDate(d) { return MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); }
| |
| // Today · 1 day ago · 5 days ago (under a week) · Sep 16, 2026
| |
| function relDate(d, now) {
| |
| now = now || new Date();
| |
| var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
| |
| var b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
| |
| var days = Math.round((b - a) / 86400000);
| |
| if (days <= 0) return 'Today';
| |
| if (days === 1) return '1 day ago';
| |
| if (days < 7) return days + ' days ago';
| |
| return absDate(d);
| |
| }
| |
| | |
| // ── data ───────────────────────────────────────────────────
| |
| function getJSON(url) {
| |
| return fetch(url, { credentials: 'same-origin' }).then(function (r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status);
| |
| return r.json();
| |
| });
| |
| }
| |
| function api(params) {
| |
| var q = ['format=json', 'formatversion=2'];
| |
| for (var k in params) q.push(k + '=' + encodeURIComponent(params[k]));
| |
| return getJSON(script('api') + '?' + q.join('&'));
| |
| }
| |
| | |
| // Devanagari names come from window.grTitles (the Main Page catalogue,
| |
| // cached) — the same names search and links use.
| |
| function fetchCatalogue() {
| |
| return new Promise(function (resolve) {
| |
| if (window.grTitles) window.grTitles.whenReady(function () { resolve(window.grTitles.all()); });
| |
| else resolve({});
| |
| });
| |
| }
| |
| | |
| // Every main-namespace change in the window, oldest→newest pages grouped.
| |
| function fetchChanges() {
| |
| var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z');
| |
| var all = [];
| |
| function page(cont) {
| |
| var p = {
| |
| action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0,
| |
| rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: 500, rcend: since
| |
| };
| |
| if (cont) p.rccontinue = cont;
| |
| return api(p).then(function (j) {
| |
| all = all.concat((j.query && j.query.recentchanges) || []);
| |
| // Two pages (1,000 changes) is plenty for a notifications list.
| |
| if (j['continue'] && j['continue'].rccontinue && all.length < 1000) return page(j['continue'].rccontinue);
| |
| return all;
| |
| });
| |
| } | | } |
| return page(null);
| | }, 100); |
| } | | }); |
| | | $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){ |
| function summarise(changes, catalogue) {
| | var id = $(this).attr('data-gra-id'); |
| var byTitle = {};
| | openPanel('bookmarks'); |
| changes.forEach(function (c) {
| | setTimeout(function(){ |
| var t = norm(c.title);
| | var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]'); |
| if (SKIP_TITLE.test(t) || HIDE.indexOf(t) !== -1) return;
| | // CHANGED: was missing the gra-card-active flash entirely (only |
| var e = byTitle[t] || (byTitle[t] = { title: t, created: null, last: null, bytes: 0 });
| | // notes and footnotes had it) — a separate inconsistency from |
| var ts = new Date(c.timestamp);
| | // the CSS gap below. Now matches the other two exactly. |
| if (c.type === 'new') e.created = ts;
| | if ($card.length) { |
| else e.bytes += Math.abs((c.newlen || 0) - (c.oldlen || 0));
| | $card.addClass('gra-card-active'); |
| if (!e.last || ts > e.last) e.last = ts;
| | $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); |
| });
| | setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); |
| var out = [];
| |
| Object.keys(byTitle).forEach(function (t) {
| |
| var e = byTitle[t];
| |
| var listed = !!catalogue[t];
| |
| // Pages the catalogue doesn't know about still count if they look like
| |
| // real content: a top-level work or a commentary under /Vyakhya/.
| |
| if (!listed && t.indexOf('/') !== -1 && !/\/Vyakhya\/[^/]+$/.test(t)) return;
| |
| if (e.created) out.push({ title: t, kind: 'new', when: e.created });
| |
| else if (e.bytes >= UPDATE_MIN_BYTES) out.push({ title: t, kind: 'update', when: e.last });
| |
| });
| |
| out.forEach(function (x) { x.listed = !!catalogue[x.title]; x.label = labelFor(x.title, catalogue); x.sub = subFor(x.title, catalogue); });
| |
| return out.sort(function (a, b) { return b.when - a.when; }); | |
| }
| |
| function labelFor(t, cat) {
| |
| if (cat[t]) return cat[t];
| |
| var parts = t.split('/');
| |
| return parts[parts.length - 1]; | |
| }
| |
| // Second line: which work a commentary belongs to.
| |
| function subFor(t, cat) {
| |
| var m = /^([^/]+)\/Vyakhya\//.exec(t); | |
| if (!m) return ''; | |
| return cat[m[1]] ? cat[m[1]] + ' — टीका' : 'टीका';
| |
| }
| |
| | |
| // Drop pages created in the window and deleted since.
| |
| function dropMissing(items) {
| |
| if (!items.length) return Promise.resolve(items); | |
| var batches = [];
| |
| for (var i = 0; i < items.length; i += 50) batches.push(items.slice(i, i + 50));
| |
| return Promise.all(batches.map(function (b) {
| |
| return api({ action: 'query', prop: 'info', inprop: 'displaytitle', titles: b.map(function (x) { return x.title; }).join('|') })
| |
| .then(function (j) {
| |
| var gone = {}, shown = {};
| |
| ((j.query && j.query.pages) || []).forEach(function (p) {
| |
| if (p.missing) gone[norm(p.title)] = true;
| |
| var dt = (p.displaytitle || '').replace(/<[^>]*>/g, '').trim();
| |
| if (dt && norm(dt) !== norm(p.title)) shown[norm(p.title)] = dt;
| |
| });
| |
| return b.filter(function (x) {
| |
| // A DISPLAYTITLE only helps pages the catalogue doesn't name.
| |
| if (shown[x.title] && !x.listed) x.label = shown[x.title];
| |
| return !gone[x.title];
| |
| });
| |
| }).catch(function () { return b; });
| |
| })).then(function (r) { return [].concat.apply([], r); });
| |
| }
| |
| | |
| function loadUpdates() {
| |
| var cached = ssGet(CACHE_KEY);
| |
| if (cached) {
| |
| try {
| |
| var c = JSON.parse(cached);
| |
| if (Date.now() - c.at < CACHE_MINUTES * 60000) {
| |
| return Promise.resolve(c.items.map(function (x) { x.when = new Date(x.when); return x; }));
| |
| }
| |
| } catch (e) { }
| |
| } | | } |
| return Promise.all([fetchChanges(), fetchCatalogue()]).then(function (r) {
| | }, 100); |
| return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10));
| | }); |
| }).then(function (items) {
| | // CHANGED: was .gra-qe-footnote-ref a with e.preventDefault() (it used |
| items = items.slice(0, MAX_ITEMS);
| | // to be a real link). Now a plain highlight span, same as notes/ |
| ssSet(CACHE_KEY, JSON.stringify({ at: Date.now(), items: items }));
| | // bookmarks — no href to prevent, id read directly via data-gra-id, |
| return items;
| | // and the handler is now identical in shape to the two right above it. |
| });
| | $(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){ |
| } | | var id = $(this).attr('data-gra-id'); |
| function lastSeen() { return +lsGet(SEEN_KEY) || 0; }
| | openPanel('footnotes'); |
| function markSeen() { lsSet(SEEN_KEY, String(Date.now())); }
| | setTimeout(function(){ |
| | | var $card = $paneFootnotes.find('[data-gra-id="'+id+'"]'); |
| // ── Updates page: the automatic list ───────────────────────── | | if ($card.length) { |
| // Filled into <div id="gr-updates-feed"> on UPDATES_PAGE.
| | $card.addClass('gra-card-active'); |
| // All styling lives in Common.css §37.
| | $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); |
| var KIND = { 'new': ['New', 'gr-upd-new'], 'update': ['Updated', 'gr-upd-update'] };
| | setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); |
| | |
| // Today · This week · This month · then one group per month ("August 2026")
| |
| var MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
| |
| function groupName(d, now) {
| |
| var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
| |
| var b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
| |
| var days = Math.round((b - a) / 86400000);
| |
| if (days <= 0) return 'Today';
| |
| if (days < 7) return 'This week';
| |
| if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) return 'Earlier this month';
| |
| return MONTHS_LONG[d.getMonth()] + ' ' + d.getFullYear();
| |
| }
| |
| | |
| function renderFeed(box, items, seenBefore) { | |
| while (box.firstChild) box.removeChild(box.firstChild);
| |
| if (items === null) { box.appendChild(el('p', 'gr-upd-empty', 'Loading…')); return; }
| |
| if (!items.length) { box.appendChild(el('p', 'gr-upd-empty', 'No new or updated pages in the last ' + WINDOW_DAYS + ' days.')); return; }
| |
| var now = new Date(), group = null, list = null;
| |
| items.forEach(function (x) {
| |
| var g = groupName(x.when, now);
| |
| if (g !== group) {
| |
| group = g;
| |
| // A div, not an h3: the site's heading rules would restyle an h3.
| |
| var h = el('div', 'gr-upd-group', g);
| |
| h.setAttribute('role', 'heading'); h.setAttribute('aria-level', '3');
| |
| box.appendChild(h);
| |
| list = el('div', 'gr-upd-list');
| |
| list.setAttribute('role', 'list');
| |
| box.appendChild(list);
| |
| }
| |
| var li = el('div', 'gr-upd-item' + (seenBefore && x.when.getTime() > seenBefore ? ' gr-upd-unseen' : ''));
| |
| li.setAttribute('role', 'listitem');
| |
| var main = el('div', 'gr-upd-main');
| |
| var a = el('a', 'gr-upd-title', x.label);
| |
| a.href = wikiHref(x.title);
| |
| main.appendChild(a);
| |
| var sub = el('div', 'gr-upd-sub');
| |
| sub.appendChild(el('span', 'gr-upd-tag ' + KIND[x.kind][1], KIND[x.kind][0]));
| |
| if (x.sub) sub.appendChild(el('span', 'gr-upd-work', x.sub));
| |
| main.appendChild(sub);
| |
| li.appendChild(main);
| |
| var d = el('time', 'gr-upd-date', relDate(x.when, now));
| |
| d.setAttribute('datetime', x.when.toISOString());
| |
| d.title = absDate(x.when);
| |
| li.appendChild(d);
| |
| list.appendChild(li);
| |
| });
| |
| }
| |
| | |
| // ── Updates page: the bulletin card ───────────────────────── | |
| // Items are written on the page with {{Bulletin|…}}; this only
| |
| // formats their dates, marks past events, and shows an empty note.
| |
| function decorateBulletin() { | |
| var card = document.querySelector('.gr-bulletin');
| |
| if (!card) return;
| |
| var now = new Date(), today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
| |
| var items = card.querySelectorAll('.gr-bulletin-item');
| |
| Array.prototype.forEach.call(items, function (it) {
| |
| var raw = it.getAttribute('data-date') || '';
| |
| var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
| |
| var d = m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
| |
| var slot = it.querySelector('.gr-bulletin-date');
| |
| if (d && slot) { slot.textContent = absDate(d); slot.setAttribute('title', raw); }
| |
| // Past events stay listed, dimmed; hide them with until= on the item.
| |
| if (d && it.getAttribute('data-type') === 'event' && d < today) it.classList.add('gr-bulletin-past');
| |
| var until = /^(\d{4})-(\d{2})-(\d{2})$/.exec((it.getAttribute('data-until') || '').trim());
| |
| if (until && new Date(+until[1], +until[2] - 1, +until[3]) < today) it.classList.add('gr-bulletin-expired');
| |
| }); | |
| var visible = card.querySelectorAll('.gr-bulletin-item:not(.gr-bulletin-expired)').length;
| |
| card.classList.toggle('gr-bulletin-is-empty', visible === 0);
| |
| var layout = card.closest('.gr-updates-layout');
| |
| if (layout) layout.classList.toggle('gr-no-bulletin', visible === 0);
| |
| }
| |
| | |
| function initUpdatesPage() {
| |
| var box = document.getElementById('gr-updates-feed');
| |
| decorateBulletin();
| |
| if (!box || box.getAttribute('data-ready')) return;
| |
| box.setAttribute('data-ready', '1');
| |
| var seenBefore = lastSeen();
| |
| renderFeed(box, null);
| |
| loadUpdates().then(function (items) {
| |
| renderFeed(box, items, seenBefore);
| |
| markSeen();
| |
| }).catch(function () { renderFeed(box, []); });
| |
| }
| |
| | |
| // ── header links ─────────────────────────────────────────────
| |
| function updatesHref() { return wikiHref(UPDATES_PAGE); }
| |
| | |
| // Which wiki page a link points at ("My wiki:Help"), or null.
| |
| function linkTarget(a) {
| |
| var href = a.getAttribute('href') || '';
| |
| if (!href || href.charAt(0) === '#') return null; // e.g. the Log out button
| |
| var u;
| |
| try { u = new URL(href, location.href); } catch (e) { return null; }
| |
| if (u.origin !== location.origin) return null;
| |
| var t = u.searchParams.get('title');
| |
| if (!t) {
| |
| var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0];
| |
| if (u.pathname.indexOf(ap) !== 0) return null;
| |
| t = u.pathname.slice(ap.length);
| |
| } | | } |
| try { t = decodeURIComponent(t); } catch (e) { }
| | }, 100); |
| return norm(t);
| | }); |
| } | |
| // Highlight whichever header / menu link is the page being viewed —
| |
| // Help, About, Updates, Profile … not only the ones this block adds.
| |
| function markCurrent() {
| |
| var me = norm(cfg('wgPageName'));
| |
| if (!me) return;
| |
| var links = document.querySelectorAll('.gr-header-link, #gr-header-user-menu a, .gr-mob-item');
| |
| Array.prototype.forEach.call(links, function (a) {
| |
| var on = a.tagName === 'A' && linkTarget(a) === me;
| |
| a.classList.toggle('is-current', on);
| |
| if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
| |
| });
| |
| }
| |
|
| |
|
| function injectDesktop() { | | // NEW: cross-document links from QuickEdit's Link picker. No real |
| if (document.getElementById('gr-library-link')) return true;
| | // href is ever saved (confirmed directly: MediaWiki's sanitizer |
| var help = document.getElementById('gr-help-link');
| | // escapes <a href> into visible literal text on this wiki, even for |
| var host = help ? help.parentNode :
| | // a full absolute URL) — the target lives in data-gr-href instead, |
| (document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header'));
| | // and this handler is what actually performs the navigation. |
| if (!host) return false;
| | $(CONTENT_SEL).on('click', '.gr-crosslink', function(){ |
| | var href = $(this).attr('data-gr-href'); |
| | if (!href) return; |
| | var newWin = window.open(href, '_blank'); |
| | if (newWin) newWin.opener = null; // more reliable cross-browser than relying on window.open's features string |
| | }); |
|
| |
|
| var lib = el('a', 'gr-header-link', LIBRARY_LABEL);
| | $(document).on('keydown', function(e){ |
| lib.id = 'gr-library-link';
| | if (e.key !== 'Escape') return; |
| lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
| | if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer(); |
| lib.title = 'Grantha catalogue (opens in a new tab)';
| | else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer(); |
| | else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer(); |
| | else closePanel(); |
| | }); |
| | } |
|
| |
|
| var upd = el('a', 'gr-header-link', UPDATES_LABEL);
| | // Re-anchors note highlights from _notes, the way bookmarks have always |
| upd.id = 'gr-updates-link';
| | // re-anchored from _bookmarks. This used to read a third store — |
| upd.href = updatesHref();
| | // NT_LS_KEY + '_hl' — holding {id, quote} pairs that _notes already had, |
| | // and which nothing kept in step once notes could also arrive from the |
| | // server. The old key is left alone rather than deleted; it is simply no |
| | // longer consulted. |
| | function restoreNoteHighlights() { |
| | _notes.forEach(function(h){ |
| | if (!h.quote || !h.id) return; |
| | if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return; |
| | var needle = h.quote.replace(/…$/,'').trim().slice(0,80); |
| | if (!needle) return; |
| | var range = findTextInContent(document.querySelector(CONTENT_SEL), needle); |
| | if (!range) return; |
| | var sp = document.createElement('span'); |
| | sp.className = 'gra-note-highlight'; |
| | sp.setAttribute('data-gra-id', h.id); |
| | try { range.surroundContents(sp); } catch(e){} |
| | }); |
| | } |
|
| |
|
| if (help) { host.insertBefore(lib, help); host.insertBefore(upd, help); }
| | function restoreBookmarkHighlights() { |
| else { host.appendChild(lib); host.appendChild(upd); }
| | _bookmarks.forEach(function(b){ |
| markCurrent();
| | if (!b.quote) return; |
| return true;
| | if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return; |
| }
| | var needle = b.quote.replace(/…$/,'').trim().slice(0,60); |
| | | if (!needle) return; |
| function injectMobile() {
| | var found = findTextInContent(document.querySelector(CONTENT_SEL), needle); |
| if (document.getElementById('gr-mob-library')) return true;
| | if (!found) return; |
| var menu = document.getElementById('gr-mob-menu-items');
| | var sp = document.createElement('span'); |
| if (!menu) return false;
| | sp.className = 'gra-bookmark-highlight'; |
| | | sp.setAttribute('data-gra-id', b.id); |
| var lib = el('a', 'gr-mob-item', LIBRARY_LABEL);
| | sp.setAttribute('data-gra-name', b.name); |
| lib.id = 'gr-mob-library';
| | try { found.surroundContents(sp); } catch(e){} |
| lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
| | }); |
| | | } |
| var upd = el('a', 'gr-mob-item', UPDATES_LABEL);
| |
| upd.id = 'gr-mob-updates';
| |
| upd.href = updatesHref();
| |
| | |
| var home = null;
| |
| for (var i = 0; i < menu.children.length; i++) {
| |
| if (/^\s*Home\s*$/.test(menu.children[i].textContent)) { home = menu.children[i]; break; }
| |
| }
| |
| var ref = home ? home.nextSibling : menu.firstChild;
| |
| menu.insertBefore(lib, ref);
| |
| menu.insertBefore(upd, ref);
| |
| markCurrent();
| |
| return true;
| |
| }
| |
|
| |
|
| // ── boot: the Help/About and mobile-menu code creates the anchors on
| | function findTextInContent(root, needle) { |
| // DOMContentLoaded, so wait briefly for them rather than assume order.
| | if (!root || !needle) return null; |
| function boot() {
| | var text = root.textContent || ''; |
| initUpdatesPage();
| | var idx = text.indexOf(needle); |
| var inject = isMobile ? injectMobile : injectDesktop;
| | if (idx < 0) return null; |
| if (inject()) return;
| | var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); |
| var tries = 0;
| | var pos = 0, node, startNode, startOffset, endNode, endOffset; |
| var obs = window.MutationObserver ? new MutationObserver(function () { if (inject()) obs.disconnect(); }) : null;
| | while ((node = iter.nextNode())) { |
| if (obs) obs.observe(document.body, { childList: true, subtree: true });
| | var len = node.nodeValue.length; |
| (function poll() {
| | if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; } |
| if (inject()) { if (obs) obs.disconnect(); return; }
| | var endIdx = idx + needle.length; |
| if (++tries < 40) setTimeout(poll, 250); else if (obs) obs.disconnect();
| | if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; } |
| }());
| | pos += len; |
| } | | } |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | | if (!startNode || !endNode) return null; |
| else boot();
| | try { |
| // Page previews and live-preview re-render content; re-run the page part then.
| | var r = document.createRange(); |
| if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { initUpdatesPage(); }); | | r.setStart(startNode, startOffset); |
| | r.setEnd(endNode, endOffset); |
| | return r; |
| | } catch(e){ return null; } |
| | } |
|
| |
|
| window.grUpdates = { relDate: relDate, summarise: summarise, groupName: groupName };
| | // REMOVED: injectAnnotationCss(), which JS-injected a defensive |
| }());
| | // .gra-card-active/.gra-hl-active rule here as a guess, since the real |
| | // gr_annotations.css wasn't available at the time. Now that it is, |
| | // confirmed precisely: .gra-hl-active already had working CSS for |
| | // notes and footnotes (that JS rule was unneeded there, though it did |
| | // fill a real gap for bookmarks' .gra-hl-active, which had none at |
| | // all) — and .gra-card-active genuinely only had a rule for |
| | // .gra-note-card, nothing for bookmark or footnote cards, confirming |
| | // that diagnosis exactly. The JS version also risked overriding the |
| | // existing, working note-card style with !important. Fixed directly |
| | // in gr_annotations.css instead — see that file. |
|
| |
|
| | $(function() { |
| | _mobile = window.innerWidth < 768 || 'ontouchstart' in window; |
| | window.addEventListener('resize', function(){ |
| | _mobile = window.innerWidth < 768 || 'ontouchstart' in window; |
| | }); |
| | buildDom(); |
| | wireEvents(); |
|
| |
|
| /* ═══════════════════════════════════════════════════════════════
| | function paint() { |
| Main Page cards: item count beside each title, and a
| | try { restoreNoteHighlights(); } catch(e){} |
| .gr-home-card-wide class on long lists. Styling: Common.css §25.
| | try { restoreBookmarkHighlights(); } catch(e){} |
| ═══════════════════════════════════════════════════════════════ */
| | if ($panel.hasClass('gra-panel-open')) switchTab(_activeTab); |
| (function () {
| | updateBadge(); |
| 'use strict';
| |
| var WIDE_AT = 14; // cards with this many items or more span two columns
| |
| function decorate() {
| |
| var cards = document.querySelectorAll('.gr-home .gr-home-card');
| |
| Array.prototype.forEach.call(cards, function (card) {
| |
| var title = card.querySelector('.gr-home-card-title');
| |
| var n = card.querySelectorAll('li').length;
| |
| if (!n) return;
| |
| if (n >= WIDE_AT) card.classList.add('gr-home-card-wide');
| |
| if (!title || title.querySelector('.gr-home-card-count')) return;
| |
| var label = document.createElement('span');
| |
| while (title.firstChild) label.appendChild(title.firstChild);
| |
| title.appendChild(label);
| |
| var c = document.createElement('span');
| |
| c.className = 'gr-home-card-count';
| |
| c.textContent = String(n);
| |
| title.appendChild(c);
| |
| });
| |
| } | | } |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
| |
| else decorate();
| |
| }());
| |
|
| |
|
| | // The cache is in place the moment load() returns, so the first paint |
| | // still happens on the old 500 ms timer and feels unchanged. The |
| | // server's answer arrives later and paints again — highlights it adds |
| | // are skipped if already present, so the second pass only fills in what |
| | // the cache did not have: a note made on the phone, or in another |
| | // browser. |
| | var loaded = Store.load(); |
| | setTimeout(paint, 500); |
| | loaded.always(paint); |
| | }); |
|
| |
|
| // ── Footnote markers and their cards ──────────────────────────────── | | // Small count on the toggle, so a reader can tell there is something in |
| /* QuickEdit already stores footnotes: it wraps the annotated words in
| | // the panel without opening it — worth having now that notes can arrive |
| <span class="gra-qe-footnote-highlight" data-gra-id="…"> and appends a
| | // from another device. |
| <div class="gra-qe-footnotes"><ol> at the foot of the document, one
| | function updateBadge() { |
| <li data-gra-id="…"> per note carrying the quoted words and the note.
| | var n = _notes.length + _bookmarks.length; |
| | | var $b = $('#gra-toggle-badge'); |
| That is the whole apparatus, but reading it meant scrolling to the end of
| | if (!$b.length) return; |
| a long document and scrolling back. This turns the same data into what
| | $b.text(n > 99 ? '99+' : String(n)).css('display', n ? 'block' : 'none'); |
| Setutila shows: a small superscript after the annotated words, and a card
| | } |
| at the point of reading. Nothing is stored differently and QuickEdit is
| |
| untouched — the list is still written, still saved, and still printed; it
| |
| is only taken off the screen, where the markers replace it.
| |
| | |
| The note text is transliterated with the rest of the page: the card's body
| |
| is tagged data-deva and announced through gr-new-content, which the script
| |
| switcher already listens for. */
| |
| (function () {
| |
| 'use strict';
| |
| | |
| var HL = '.gra-qe-footnote-highlight';
| |
| var LIST = '.gra-qe-footnotes';
| |
| var TYPES = { // data-gra-type on the <li>, if set
| |
| pathantara: 'पाठान्तरम्',
| |
| source: 'मूलम्',
| |
| note: 'टिप्पणी'
| |
| };
| |
| | |
| var card = null, openFor = null;
| |
| | |
| function notesFromList() {
| |
| var out = {};
| |
| document.querySelectorAll(LIST + ' .gra-qe-footnote-item').forEach(function (li) {
| |
| var id = li.getAttribute('data-gra-id');
| |
| if (!id) return;
| |
| var textEl = li.querySelector('.gra-qe-footnote-text');
| |
| out[id] = {
| |
| text: (textEl ? textEl.textContent : li.textContent).trim(),
| |
| quote: (li.querySelector('.gra-qe-footnote-quote') || {}).textContent || '',
| |
| type: li.getAttribute('data-gra-type') || 'note'
| |
| };
| |
| });
| |
| return out;
| |
| }
| |
| | |
| function buildCard() {
| |
| if (card) return card;
| |
| card = document.createElement('div');
| |
| card.className = 'gr-note-card';
| |
| card.setAttribute('role', 'dialog');
| |
| card.hidden = true;
| |
| card.innerHTML =
| |
| '<div class="gr-note-card-head">' +
| |
| '<span class="gr-note-card-kind"></span>' +
| |
| '<button type="button" class="gr-note-card-close" aria-label="Close">×</button>' +
| |
| '</div><div class="gr-note-card-body"></div>';
| |
| card.querySelector('.gr-note-card-close').addEventListener('click', close);
| |
| document.body.appendChild(card);
| |
| return card;
| |
| }
| |
| | |
| function close() {
| |
| if (!card || card.hidden) return;
| |
| card.hidden = true;
| |
| if (openFor) {
| |
| openFor.setAttribute('aria-expanded', 'false');
| |
| openFor.classList.remove('gr-note-marker-open');
| |
| }
| |
| openFor = null;
| |
| }
| |
| | |
| // Anchored under the marker, nudged back inside the viewport rather than
| |
| // flipped: these sit in running text, and a card that jumps above the line
| |
| // is harder to connect to the word it belongs to than one that shifts left.
| |
| function place(marker) {
| |
| var r = marker.getBoundingClientRect();
| |
| var w = card.offsetWidth, h = card.offsetHeight;
| |
| var pad = 10;
| |
| var left = r.left + r.width / 2 - w / 2;
| |
| left = Math.max(pad, Math.min(left, window.innerWidth - w - pad));
| |
| var top = r.bottom + 8;
| |
| if (top + h > window.innerHeight - pad && r.top - h - 8 > pad) top = r.top - h - 8;
| |
| card.style.left = Math.round(left) + 'px';
| |
| card.style.top = Math.round(top) + 'px';
| |
| }
| |
| | |
| function open(marker, note, num) {
| |
| buildCard();
| |
| if (openFor === marker && !card.hidden) { close(); return; }
| |
| close();
| |
| card.querySelector('.gr-note-card-kind').textContent =
| |
| (TYPES[note.type] || TYPES.note) + ' [' + num + ']';
| |
| var body = card.querySelector('.gr-note-card-body');
| |
| // A bare text node, deliberately. gr-new-content's handler wraps it,
| |
| // records it as translatable and applies whatever script is showing —
| |
| // tagging it here instead would make the handler skip it as already
| |
| // done, and the note would sit in Devanagari on a Telugu page.
| |
| body.textContent = note.text;
| |
| card.hidden = false;
| |
| place(marker);
| |
| try {
| |
| window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: body } }));
| |
| } catch (e) { }
| |
| marker.setAttribute('aria-expanded', 'true');
| |
| marker.classList.add('gr-note-marker-open');
| |
| openFor = marker;
| |
| } | |
| | |
| function decorate() {
| |
| var notes = notesFromList();
| |
| var n = 0;
| |
| document.querySelectorAll('#mw-content-text ' + HL).forEach(function (hl) {
| |
| var id = hl.getAttribute('data-gra-id');
| |
| var note = id && notes[id];
| |
| if (!note || !note.text) return; // orphan highlight: leave it alone
| |
| n++;
| |
| if (hl.nextElementSibling &&
| |
| hl.nextElementSibling.classList.contains('gr-note-marker')) {
| |
| hl.nextElementSibling.textContent = '[' + n + ']';
| |
| return;
| |
| }
| |
| var m = document.createElement('sup');
| |
| m.className = 'gr-note-marker';
| |
| m.textContent = '[' + n + ']';
| |
| m.setAttribute('role', 'button');
| |
| m.setAttribute('tabindex', '0');
| |
| m.setAttribute('aria-expanded', 'false');
| |
| var num = n;
| |
| function show(e) { e.preventDefault(); e.stopPropagation(); open(m, note, num); }
| |
| m.addEventListener('click', show);
| |
| m.addEventListener('keydown', function (e) {
| |
| if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(m, note, num); }
| |
| });
| |
| // The annotated words open it as well. On a phone the marker is a
| |
| // small target, and the words are what the reader is looking at.
| |
| hl.addEventListener('click', show);
| |
| hl.parentNode.insertBefore(m, hl.nextSibling);
| |
| });
| |
| var list = document.querySelector(LIST);
| |
| if (list) list.classList.toggle('gr-notes-listed', n > 0);
| |
| }
| |
| | |
| document.addEventListener('click', function (e) {
| |
| if (!card || card.hidden) return;
| |
| if (!card.contains(e.target)) close();
| |
| });
| |
| document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
| |
| window.addEventListener('resize', function () { if (openFor) place(openFor); });
| |
| window.addEventListener('scroll', function () { if (openFor) place(openFor); }, true);
| |
|
| |
|
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
| | }() ); |
| else decorate();
| |
| if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { setTimeout(decorate, 150); });
| |
| }()); | |