MediaWiki:Common.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1,113: | Line 1,113: | ||
return ap.replace('$1', title); | 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 | |||
// different teekas are interleaved together and need to stay | |||
// visually distinguishable. Palette given directly for this exact | |||
// purpose. Transcribed from a screenshot, so noting honestly rather | |||
// than silently "cleaning up": #10/#11 both read as #FAFAD2, and | |||
// #1/#16 both read as #E8F5E9 - kept as given. | |||
var TEEKA_COLOR_PALETTE = [ | |||
'#E8F5E9', '#F0F8FF', '#FDF5E6', '#FFF0F5', '#FFE4E1', '#FFEFD5', '#FFE5F6', '#F3E5FB', | |||
'#F3C5C5', '#FAFAD2', '#FAFAD2', '#E3E589', '#E6F5B6', '#E6C5C0', '#F5F5C0', '#E8F5E9', | |||
'#FEF5C8', '#F6FEC6', '#F6EECF', '#F5FEC3', '#F6CEF0', '#F6C500', '#F6CEC8', '#F6C6C0', '#EBFFC0' | |||
]; | |||
// 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]; | |||
} | |||
function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } | |||
function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } } | |||
function fetchAllSubpages(rootTitle) { | |||
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(); | |||
} | |||
function fetchTeekaJson(title) { | |||
return $.ajax({ | |||
url: mw.util.wikiScript('api'), | |||
data: { action: 'query', titles: 'JSON:' + title, prop: 'revisions', rvslots: 'main', rvprop: 'content', format: 'json', formatversion: 2 }, | |||
dataType: 'json' | |||
}).then(function (data) { | |||
var pages = (data.query && data.query.pages) || []; | |||
var page = pages[0]; | |||
if (!page || page.missing) return null; | |||
var content = page.revisions && page.revisions[0] && page.revisions[0].slots.main.content; | |||
if (!content) return null; | |||
try { var parsed = JSON.parse(content); parsed._title = title; return parsed; } | |||
catch (e) { return null; } | |||
}).catch(function () { return null; }); | |||
} | |||
// Finds this document's commentary subpages - filters to the site's | |||
// own established commentary-path convention (Vyakhya/Bhashya are | |||
// shown; Moola/Ullekha are never commentary content). | |||
function findCommentarySubpages(rootTitle) { | |||
return $.Deferred(function (d) { | |||
fetchAllSubpages(rootTitle).then(function (all) { | |||
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}]. | |||
// 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. | |||
function buildTeekaInMoolaIndex(teekaDocs) { | |||
var index = {}; | |||
teekaDocs.forEach(function (td) { | |||
if (!td || !td.blocks || !td.doc) return; | |||
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: td.doc.slug || td._title, | |||
text: (b.lines || []).join(' '), | |||
blockId: b.id | |||
}); | |||
}); | |||
}); | |||
return index; | |||
} | |||
function injectTeekaInMoola(index) { | |||
var injectedAny = false; | |||
Object.keys(index).forEach(function (verseId) { | |||
var $verseEl = $(document.getElementById(verseId)); | |||
if (!$verseEl.length) return; | |||
var entries = index[verseId]; | |||
var $wrap = $('<div>').addClass('gr-injected-teeka-group'); | |||
entries.forEach(function (entry) { | |||
var $entry = $('<div>').addClass('teeka-block') | |||
.attr('data-teeka-slug', entry.teekaSlug) | |||
.attr('data-teeka-block-id', entry.blockId) | |||
.css('background', teekaColorForSlug(entry.teekaSlug)); | |||
$('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry); | |||
$('<div>').addClass('teeka-body').html(entry.text).appendTo($entry); | |||
$wrap.append($entry); | |||
}); | |||
$verseEl.after($wrap); | |||
injectedAny = true; | |||
}); | |||
return injectedAny; | |||
} | |||
function removeTeekaInMoola() { $('.gr-injected-teeka-group').remove(); } | |||
function normalizeWikiPath(p) { | |||
try { p = decodeURIComponent(p); } catch (e) { } | |||
return p.replace(/_/g, ' ').replace(/\/+$/, ''); | |||
} | |||
// ── State ────────────────────────────────────────────────────── | |||
var selectedTeekaSlugs = {}; | |||
try { JSON.parse(readLS(LS_TEEKA_KEY) || '[]').forEach(function (s) { selectedTeekaSlugs[s] = true; }); } catch (e) { } | |||
var teekaIndex = null; | |||
var availableDocs = []; | |||
var checkboxesBySlug = {}; | |||
var readyCallbacks = []; | |||
var changeCallbacks = []; | |||
var visibilityCallbacks = []; | |||
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); } | |||
function renderInterleave() { | |||
removeTeekaInMoola(); | |||
if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return; | |||
var filtered = {}; | |||
Object.keys(teekaIndex).forEach(function (verseId) { | |||
var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; }); | |||
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) { | |||
if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug]; | |||
writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs))); | |||
(checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); }); | |||
renderInterleave(); | |||
changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); }); | |||
} | |||
// Augments existing <a> links pointing to a known commentary subpage | |||
// 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. | |||
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 $cb = $('<input>').attr({ type: 'checkbox' }).addClass('gr-teeka-inline-checkbox').prop('checked', !!selectedTeekaSlugs[slug]); | |||
$(this).before($cb, ' '); | |||
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 | |||
// notifies subscribers only when that overall state actually flips - | |||
// verified in isolation before wiring in here. Drives whether | |||
// readerToolbar.js's "Commentaries" control needs to be shown as a | |||
// fallback (inline list scrolled out of view) or can stay hidden | |||
// (reader can already see and use the inline checkboxes). | |||
function observeInlineVisibility(checkboxEls) { | |||
if (!checkboxEls.length || !window.IntersectionObserver) return; | |||
var visibleSet = new Set(); | |||
var obs = new IntersectionObserver(function (entries) { | |||
entries.forEach(function (entry) { | |||
var id = entry.target.__grTeekaId; | |||
if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id); | |||
}); | |||
var anyVisible = visibleSet.size > 0; | |||
if (anyVisible !== inlineListVisible) { | |||
inlineListVisible = anyVisible; | |||
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() { | |||
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'); | |||
findCommentarySubpages(rootTitle).then(function (subpages) { | |||
if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; } | |||
$.when.apply($, subpages.map(fetchTeekaJson)).then(function () { | |||
var fetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments); | |||
// Pair title+doc together FIRST, then filter as one unit - | |||
// filtering `docs` separately from `subpages` and passing | |||
// both (now misaligned) arrays elsewhere was a confirmed | |||
// real bug in an earlier version of this feature. | |||
var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; }) | |||
.filter(function (p) { | |||
if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title); | |||
return !!p.doc; | |||
}); | |||
var docs = pairs.map(function (p) { return p.doc; }); | |||
teekaIndex = buildTeekaInMoolaIndex(docs); | |||
availableDocs = docs; | |||
var augmented = augmentExistingTeekaLinks(pairs.map(function (p) { return p.title; }), 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(docs.map(function (d) { return { slug: d.doc.slug || d._title, title: d.doc.title || d._title }; })); }); | |||
if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); }); | |||
}); | |||
}); | |||
} | |||
window.grTeekaInterleave = { | |||
onReady: function (cb) { if (isReady) cb(availableDocs.map(function (d) { return { slug: d.doc.slug || d._title, title: d.doc.title || d._title }; })); 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(); | |||
}()); | }()); | ||