MediaWiki:Gadget-GrAnnotations.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab)
*/
/* global mw, $ */
( function () {
'use strict';
var CONTENT_SEL = '#mw-content-text';
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 = '';
// FIXED: was `new mw.Api()` called directly and synchronously here —
// 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
// 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
// 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 () {
return new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 });
} ).then( function (data) {
var info = data && data.query && data.query.userinfo;
if ( info && info.email ) currentUserEmail = info.email;
} ).catch( function () {} );
}
if ( window.mw ) {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns < 0 ) return;
}
var _selRange = null;
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
function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
function esc(s) {
return String(s||'').replace(/&/g,'&').replace(/</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; }
// The server caps a note at 8192 *bytes* (MAX_TEXT_BYTES), and Devanagari
// runs three bytes to the character, so the real limit is about 2,700
// characters — reachable for the kind of thing people write here. Caught
// at the composer, because the alternative is a note that looks saved,
// sits in the panel all session, and is simply absent the next time the
// page is opened.
var MAX_NOTE_BYTES = 8192;
function byteLength(s) {
try { return new TextEncoder().encode(s).length; }
catch (e) { return unescape(encodeURIComponent(s)).length; }
}
/* ══════════════════════ 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 () {
var loggedIn = !!currentUser;
// Rejections the server will never change its mind about. Anything not
// listed here is treated as temporary and retried.
var TERMINAL = { badid: 1, notowner: 1, texttoolong: 1, missingparam: 1 };
var _api = null;
var flushing = null; // the in-flight flush's promise, or null
var outbox = []; // authoritative in memory; mirrored to LS
// mw.Api only exists once mediawiki.api has loaded, and asking for it
// sooner throws. Everything that talks to the server waits on this, so a
// load failure degrades to cache-and-queue instead of throwing.
function ready() {
if ( !window.mw || !mw.loader ) return $.Deferred().reject().promise();
return mw.loader.using( 'mediawiki.api' ).then( function () {
if ( !_api ) _api = new mw.Api();
return _api;
} );
}
function ls( k, v ) {
try {
if ( v === undefined ) return localStorage.getItem( k );
localStorage.setItem( k, v );
return true;
} catch ( e ) { return null; }
}
function readJson( k, fallback ) {
try { return JSON.parse( ls( k ) || '' ) || fallback; } catch ( e ) { return fallback; }
}
function whoami() { return currentUser || null; }
/* ── Outbox ──────────────────────────────────────────────────────
* Held in memory and mirrored to localStorage, not held *in* it. A
* browser that refuses to store — private mode, quota, policy — used to
* mean the write was never sent at all, because the send re-read the
* queue it had just failed to write. Now storage only decides whether an
* unsent write survives a reload.
*
* Keyed per user AND per page: the cache keys have always been per page,
* so a queue scoped only by user cannot describe them. */
function qKey() { return Q_LS_KEY + '_' + ( currentUser || 'anon' ) + '_' + pageTitle; }
function mirror() { ls( qKey(), JSON.stringify( outbox ) ); }
function enqueue( op ) {
// One pending operation per annotation. A note edited twice before the
// network returns should send its final state once, not both states in
// order, and a delete supersedes any save still waiting behind it.
outbox = outbox.filter( function ( x ) { return x.id !== op.id; } );
outbox.push( op );
mirror();
}
// Removes the exact operation that was sent, by identity. Matching on
// id and op instead would let a completing send delete a *newer*
// operation for the same annotation that replaced it mid-flight.
function dequeue( op ) {
var i = outbox.indexOf( op );
if ( i === -1 ) return;
outbox.splice( i, 1 );
mirror();
}
function send( op ) {
var params = { action: 'granthaannotationedit', op: op.op, id: op.id, format: 'json' };
if ( op.op === 'save' ) {
params.page = op.page;
params.type = op.type;
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 ); } );
}
/* Drain the outbox oldest-first, stopping at the first failure so
* ordering is preserved and a server that is down is not hammered. */
function flush() {
if ( !loggedIn ) return $.Deferred().resolve().promise();
// Return the flush already running rather than a fresh resolved
// promise: load() waits on this before reading, and handing it an
// instantly-resolved promise would let the read race the writes.
if ( flushing ) return flushing;
if ( !outbox.length ) return $.Deferred().resolve().promise();
var d = $.Deferred();
flushing = d.promise();
( function step() {
if ( !outbox.length ) { flushing = null; d.resolve(); return; }
var op = outbox[ 0 ];
send( op ).done( function () {
dequeue( op );
// Guard against a queue that cannot shrink, which would loop
// re-POSTing the same row for ever. Checked by identity, not by
// length: a note written while this request was in flight makes
// the queue longer, and comparing lengths stopped the drain with
// that newer write still unsent.
if ( outbox.indexOf( op ) !== -1 ) { flushing = null; d.resolve(); return; }
step();
} ).fail( function ( code ) {
// Verdicts the server will never change its mind about. Left at
// the head of the queue they block every later write on this page
// for ever, so they are dropped and the drain carries on.
if ( TERMINAL[ code ] ) {
dequeue( op );
step();
return;
}
// Anything else — offline, 5xx, session expired — is temporary.
// Stop here so ordering is kept and a struggling server is not
// hammered; the next write, `online`, or page load retries.
flushing = null;
d.resolve();
} );
}() );
return flushing;
}
function write( op ) {
if ( !loggedIn ) return; // nothing to send, and nothing to accumulate
enqueue( op );
flush();
}
/* ── public ────────────────────────────────────────────────────── */
/* What has been written locally since the last answer from the server.
* The outbox alone is not enough to reconcile with: a note written while
* the initial read was in flight may well have finished its own POST
* first, and would then be gone from the outbox by the time the older
* answer arrives — which would take it off the screen and out of the
* cache. These two maps outlive the outbox entry and are cleared only
* when a read has actually been folded in. */
var localSaves = {}, localDeletes = {};
function save( type, rec ) {
localSaves[ rec.id ] = { type: type, rec: rec };
delete localDeletes[ rec.id ];
write( {
op: 'save', id: rec.id, page: pageTitle, type: type,
quote: rec.quote || '', text: type === 'bookmark' ? rec.name : rec.text,
ts: rec.ts
} );
}
function remove( id ) {
localDeletes[ id ] = true;
delete localSaves[ id ];
write( { op: 'delete', id: id } );
}
/* Cache what is on screen, so the next load of this page paints from it
* before the network answers.
*
* The cache records whose it is, for both signed-in and anonymous
* readers. The keys are per page, not per user, and always have been, so
* without an owner two people sharing a machine would see each other's
* notes — and worse, the next person to sign in would have the previous
* one's notes uploaded into their account by migrate(). Recording null
* for "nobody was signed in" is what makes those two cases tellable
* apart from genuinely pre-upgrade data. */
function cache() {
ls( NT_LS_KEY, JSON.stringify( { u: whoami(), v: _notes } ) );
ls( BM_LS_KEY, JSON.stringify( { u: whoami(), v: _bookmarks } ) );
}
function cached( key ) {
var raw = readJson( key, null );
if ( !raw ) return [];
// A bare array predates this version; only migrate() may claim it.
if ( Array.isArray( raw ) ) return [];
return ( raw.u === whoami() && Array.isArray( raw.v ) ) ? raw.v : [];
}
function fromCache() {
_notes = cached( NT_LS_KEY );
_bookmarks = cached( BM_LS_KEY );
}
/* ── Claiming ────────────────────────────────────────────────────
* Hands over whatever this browser holds with no owner: the bare array
* the old gadget wrote, and the anonymous cache this one writes. Both
* mean "notes taken on this browser with nobody signed in", which is
* what a reader who browsed logged out and then registered has.
*
* There is no "already migrated" flag, deliberately. A flag has to be
* scoped, and every scoping is wrong somewhere: per user alone marked
* every page but the first as done; per user and page was set on the
* first visit even when there was nothing to hand over, so notes taken
* anonymously *afterwards* could never be claimed and were overwritten
* on the next sign-in.
*
* 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() {
if ( !loggedIn ) return false;
var found = false;
legacy( NT_LS_KEY ).forEach( function ( n ) {
if ( !n || !n.id ) return;
// The old gadget imposed no length limit, so a note from before the
// 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
// the cache, just not synced.
if ( byteLength( n.text || '' ) > MAX_NOTE_BYTES ) return;
found = true;
enqueue( { op: 'save', id: n.id, page: pageTitle, type: 'note',
quote: n.quote || '', text: n.text || '', ts: n.ts } );
} );
legacy( BM_LS_KEY ).forEach( function ( b ) {
if ( !b || !b.id ) return;
found = true;
enqueue( { op: 'save', id: b.id, page: pageTitle, type: 'bookmark',
quote: b.quote || '', text: b.name || '', ts: b.ts } );
} );
return found;
}
/* ── Reconciliation ──────────────────────────────────────────────
* The server's answer describes the moment the request was sent. A note
* 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
* disappear. So anything still pending is re-applied on top. */
function reconcile( notes, marks ) {
function apply( list, type ) {
var out = list.filter( function ( r ) {
return !localDeletes[ r.id ] && !localSaves[ r.id ];
} );
Object.keys( localSaves ).forEach( function ( id ) {
var s = localSaves[ id ];
if ( s.type === type ) out.push( s.rec );
} );
return out;
}
return { notes: apply( notes, 'note' ), marks: apply( marks, 'bookmark' ) };
}
/* Fill _notes and _bookmarks. The cache is in place synchronously so the
* first paint is instant; the server's answer lands later. */
function load() {
outbox = readJson( qKey(), [] );
if ( !Array.isArray( outbox ) ) outbox = [];
if ( !loggedIn ) {
// localStorage is the store, not a cache. Accept the owner-stamped
// shape and, once, the bare array the old gadget wrote.
_notes = cached( NT_LS_KEY );
_bookmarks = cached( BM_LS_KEY );
if ( !_notes.length ) _notes = legacy( NT_LS_KEY );
if ( !_bookmarks.length ) _bookmarks = legacy( BM_LS_KEY );
return $.Deferred().resolve( false ).promise();
}
if ( migrate() ) {
// Show what this browser already had straight away. migrate() has
// just claimed it for whoever is signed in now, so what is on screen
// and what is being sent up are the same set — and a first run that
// happens to be offline still shows the reader their notes rather
// than an empty panel.
_notes = legacy( NT_LS_KEY );
_bookmarks = legacy( BM_LS_KEY );
cache();
} else {
fromCache();
}
var d = $.Deferred();
var rows = [];
// 500 is the server's maximum page size, and it orders newest first —
// so a reader with more than that on one page would silently lose
// their *oldest* annotations from the panel and from the cache.
// Follow the continuation until it stops rather than assuming nobody
// will get there.
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;
} );
}
flush().always( function () {
fetchFrom( null ).done( function () {
var notes = [], marks = [];
rows.forEach( function ( r ) {
if ( r.type === 'bookmark' ) {
marks.push( { id: r.id, name: r.text, quote: r.quote, ts: r.ts } );
} else {
notes.push( { id: r.id, ts: r.ts, quote: r.quote, text: r.text } );
}
} );
// The server returns newest first; the panel reverses for display,
// so hold them oldest-first the way the local arrays always were.
var merged = reconcile( notes.reverse(), marks.reverse() );
_notes = merged.notes;
_bookmarks = merged.marks;
// Folded in, so the local record of what has changed since can
// start again. Anything still unsent stays in the outbox.
localSaves = {}; localDeletes = {};
cache();
d.resolve( true );
} ).fail( function () {
// Offline, or the extension is not installed. The cache already
// loaded, and anything written meanwhile is queued.
d.resolve( false );
} );
} );
return d.promise();
}
if ( loggedIn && window.addEventListener ) {
window.addEventListener( 'online', function () { flush(); } );
}
return { load: load, save: save, remove: remove, cache: cache, flush: flush,
isRemote: function () { return loggedIn; } };
}() );
var $fab, $mobileBar, $panel, $backdrop;
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 buildDom() {
$fab = $( [
'<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">',
' <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);
$mobileBar = $('<div id="gra-mobile-bar"></div>');
$('body').append($mobileBar);
$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);
$ntComposer = $( [
'<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">',
' <div class="gra-composer-user">',
' <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
' <div class="gra-composer-uname">' + esc(currentUser || 'Notes') + '</div>',
' </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);
$bmComposer = $( [
'<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
' <div class="gra-bm-composer-label">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' 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);
$panel = $( [
'<div id="gra-panel" role="complementary" aria-label="Notes">',
' <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);
$backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>');
$('body').append($backdrop);
var $toggle = $( [
'<button id="gra-toggle" aria-label="Notes">',
' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>',
'</button>',
].join('') );
$('body').append($toggle);
$toggle.on('click', function() {
$panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
});
$('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
$tabNotes = $('#gra-tab-notes');
$tabBookmarks = $('#gra-tab-bookmarks');
$tabFootnotes = $('#gra-tab-footnotes');
$paneNotes = $('#gra-pane-notes');
$paneBookmarks= $('#gra-pane-bookmarks');
$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');
}
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;
}
function reCaptureFromDOM() {
if (!_selText) return false;
var contentEl = document.querySelector(CONTENT_SEL);
if (!contentEl) return false;
var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim());
if (!found) return false;
_selRange = found;
return true;
}
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 showFab(rect) {
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 hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
function hideActions() { hideFab(); }
function wrapSelection(id, cssClass) {
var range = _selRange;
_selRange = null;
if (!range) return null;
try {
if (!document.contains(range.startContainer) ||
!document.contains(range.endContainer)) return null;
} catch(e) { return null; }
function makeSpan() {
var sp = document.createElement('span');
sp.className = cssClass;
sp.setAttribute('data-gra-id', id);
return sp;
}
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 openFeedbackComposer() {
hideActions();
$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);
}
function closeFeedbackComposer() {
$fbComposer.removeClass('gra-composer-visible');
$backdrop.removeClass('gra-backdrop-visible');
_selRange = null; _selText = ''; _selRect = null;
}
function submitFeedback() {
var issueType = $fbIssueType.val();
var details = $fbText.val().trim();
var email = $fbEmail.val().trim();
var quote = $fbQuote.text();
if (!issueType) return;
$fbSubmit.prop('disabled', true).text('Sending…');
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
var issueLabels = {
wrong_text: 'Formatting error', reference_issue: 'Reference issue',
spelling_mistake: 'Spelling mistake', other: 'Other'
};
var payload = new FormData();
payload.append('issue_type', issueLabels[issueType] || issueType);
payload.append('page', pageTitle.replace(/_/g,' '));
payload.append('url', window.location.href);
payload.append('selected_text', quote);
payload.append('details', details || '');
payload.append('user_email', email || currentUserEmail || '');
payload.append('wiki_user', currentUser || 'anonymous');
fetch('/feedback.php', {method:'POST', body:payload})
.then(function(r){ return r.json(); })
.then(function(data){
if (data && data.ok) showFeedbackSuccess();
else showFeedbackError(data && data.error ? data.error : 'Could not send.');
})
.catch(function(){ showFeedbackError('Network error. Please try again.'); });
}
function showFeedbackSuccess() {
$fbSubmit.prop('disabled', false).text('Send');
$('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok');
setTimeout(closeFeedbackComposer, 2500);
}
function showFeedbackError(msg) {
$fbSubmit.prop('disabled', false).text('Send');
$('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
}
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);
}
function closeNoteComposer() {
$ntComposer.removeClass('gra-composer-visible');
$backdrop.removeClass('gra-backdrop-visible');
$ntInput.val('');
$ntSubmit.prop('disabled', true);
_selRange = null; _selText = ''; _selRect = null;
}
function submitNote() {
var text = $ntInput.val().trim();
if (!text) return;
if (byteLength(text) > MAX_NOTE_BYTES) {
alert('This note is too long to save — please shorten it.');
return;
}
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');
}
/* ── SHAREABLE CITATION ────────────────────────────────────────────
A bookmark is personal, but what it produces is not: a reference in
APA form plus a link that resolves for anyone.
The link cannot point at the bookmark itself. A bookmark wraps the
reader's own selection in a span with an id generated in their
browser; that span is never saved to the page, so #<bookmark-id>
resolves for nobody else. The citation therefore anchors to the
nearest enclosing block that the PUBLISHED page carries an id for -
the same ids copyId.js links to, and the same ones cross-references
across the site already use.
APA 7th, for a work on a website with no date:
Author. (n.d.). Title (Locator). Site. Retrieved D Month Y, from URL
n.d. because these works carry no publication date, and a retrieval
date because a wiki page can change. The block reference is given as
a parenthetical locator after the title, which is where APA puts a
section or part designation. */
var CITE_BLOCK_SEL = '.verse-block[id], .bhashyam-block[id], .teeka-block[id], ' +
'[data-block-id][id], .gr-apparatus[id], .shloka[id]';
function citeAnchorFor(node) {
var el = node && node.nodeType === 3 ? node.parentNode : node;
while (el && el !== document.body) {
if (el.matches && el.matches(CITE_BLOCK_SEL)) return el;
el = el.parentNode;
}
return null;
}
// Walks up from the cited block: an interleaved commentary block states
// its own work, otherwise the page's title element does.
// A reference written in English cites the work in IAST, so prefer the
// transliterated forms the library supplies and keep Devanagari as the
// fallback. common.js writes the library's values onto .gr-doc-title as
// soon as they arrive, so reading the DOM here always gets the current
// names without this module knowing the library exists.
function citeSourceFor(el) {
var n = el;
while (n && n !== document.body) {
if (n.getAttribute && n.getAttribute('data-cite-title')) {
return {
author: n.getAttribute('data-cite-author-iast') ||
n.getAttribute('data-cite-author') || '',
title: n.getAttribute('data-cite-title-iast') ||
n.getAttribute('data-cite-title') || '',
page: n.getAttribute('data-cite-page') || ''
};
}
n = n.parentNode;
}
var t = document.querySelector('.gr-doc-title');
return {
author: (t && (t.getAttribute('data-author-iast') ||
t.getAttribute('data-author'))) || '',
title: (t && (t.getAttribute('data-doc-title-iast') ||
t.getAttribute('data-doc-title') ||
t.textContent.trim())) || document.title,
page: (window.mw && mw.config.get('wgPageName')) || '',
scheme: (t && t.getAttribute('data-locator-scheme')) || ''
};
}
// Any block carrying a library id gets its author/title filled in from
// the library, whether common.js injected it or the page was built with
// it already in place. Doing it here rather than only at injection time
// means a citation never depends on which path put the block on screen.
function hydrateCitationSources(root) {
if (!window.grLibrary) return;
var nodes = (root || document).querySelectorAll('[data-cite-grantha-id]');
Array.prototype.forEach.call(nodes, function (el) {
if (el.getAttribute('data-cite-hydrated')) return;
var gid = el.getAttribute('data-cite-grantha-id');
if (!gid) return;
el.setAttribute('data-cite-hydrated', '1');
window.grLibrary.lookup(gid).then(function (g) {
if (!g) return;
if (g.name) el.setAttribute('data-cite-title', g.name);
if (g.nameIAST) el.setAttribute('data-cite-title-iast', g.nameIAST);
if (g.author) el.setAttribute('data-cite-author', g.author);
if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST);
});
});
}
// ── LOCATOR ─────────────────────────────────────────────────────────
// "KKN_C01_S01_V03" is a build artefact, not a reference. A reader
// citing it wants 1.1.3, and ideally to be told that those numbers are
// adhyaya, valli and mantra. The page declares the division names it
// knows (data-locator-scheme); where it declares none we still give the
// dotted form, which is correct for every text - it is only the NAMES
// of the divisions that vary from work to work.
function parseScheme(s) {
var out = {};
(s || '').split(',').forEach(function (pair) {
var i = pair.indexOf('=');
if (i > 0) out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
});
return out;
}
// Segments that qualify the passage rather than number it: _B01 is the
// first paragraph of the bhashya ON that verse, and a bare letter with
// no number (Katha's "_I_" for an introduction) marks a position, not a
// division. A NUMBERED segment is always part of the path - in
// RG_C01_I01_V01 the I is a sukta, and reading it as Katha's
// introduction marker turned 1.1.1 into "1.1, Sukta 1".
function isQualifier(k, n) { return k === 'B' || !n; }
function parseLocator(id) {
if (!id) return null;
var segs = String(id).split('_');
var path = [], qual = [];
for (var i = 1; i < segs.length; i++) {
var m = /^([A-Za-z]+)(\d*)$/.exec(segs[i]);
if (!m) continue;
var k = m[1].toUpperCase(), n = m[2] ? String(parseInt(m[2], 10)) : '';
(isQualifier(k, n) ? qual : path).push({ k: k, n: n });
}
if (!path.length && !qual.length) return null;
return { path: path, qual: qual };
}
function locatorText(id, scheme) {
var p = parseLocator(id);
if (!p) return { numeric: id || '', spelled: '' };
var labels = parseScheme(scheme);
var numeric = p.path.map(function (s) { return s.n; }).filter(Boolean).join('.');
var spelledParts = p.path.map(function (s) {
return labels[s.k] ? labels[s.k] + ' ' + s.n : null;
});
var spelled = spelledParts.indexOf(null) === -1 ? spelledParts.join(', ') : '';
p.qual.forEach(function (s) {
var lab = labels[s.k] ||
(s.k === 'B' ? 'bhāṣya' : (s.k === 'I' ? 'intro' : s.k));
var bit = lab + (s.n ? ' ' + s.n : '');
numeric += (numeric ? ', ' : '') + bit;
if (spelled) spelled += ', ' + bit;
});
return { numeric: numeric || id, spelled: spelled };
}
// Version stamp, so it is possible to tell from the browser console
// which build of this gadget a page is actually running. Check with
// window.grAnnotationsVersion
window.grAnnotationsVersion = '2026-09-30a';
// A citation is worthless if its link doesn't open, so prefer URLs we
// KNOW are good over ones we assemble.
// - Same page as the reader: use the address they're already at. A wiki
// whose served paths differ from wgArticlePath (or that sits behind a
// rewrite) would otherwise yield a link that 404s.
// - Another page (an injected teeka block cites its own subpage): use
// mw.util.getUrl, the same builder every working link on the wiki
// comes from. wgArticlePath is only the last resort.
function citeUrl(pageName, anchorId) {
var here = (window.mw && mw.config.get('wgPageName')) || '';
var frag = anchorId ? '#' + encodeURIComponent(anchorId) : '';
var base;
if (!pageName || String(pageName) === String(here)) {
base = location.origin + location.pathname;
} else if (window.mw && mw.util && mw.util.getUrl) {
base = location.origin + mw.util.getUrl(String(pageName));
} else {
var path = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1';
base = location.origin +
path.replace('$1', String(pageName).split('/').map(encodeURIComponent).join('/'));
}
return base + frag;
}
var CITE_MONTHS = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
function retrievedToday() {
var d = new Date();
return CITE_MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear();
}
function siteName() {
return (window.mw && mw.config.get('wgSiteName')) || location.hostname;
}
// APA 7th, webpage on a website (§10.16):
// Author. (n.d.). Title of work. Site Name. Retrieved <date>, from <URL>
// Two rules this has to honour and an earlier draft did not:
// 1. No known author -> the TITLE moves into the author position; you
// never leave a bare "(n.d.)" with nothing in front of it. And when
// the title occupies that slot the site name is not repeated after it.
// 2. A locator (our block id) does NOT belong in the reference entry.
// The parenthetical slot after a title is for edition/format
// descriptors, not section numbers. APA puts locators in the in-text
// citation, so the block id goes there: (Author, n.d., IS_C01_I03).
// The retrieval date is correct here because the page is designed to
// change and we link to the live version rather than a permalink.
// Titles of standalone works are italicised, which only the HTML flavour
// of the clipboard can carry.
function buildCitation(rec) {
var src = rec.citeSource || {};
var author = (src.author || '').trim();
var title = (src.title || '').trim();
var anchor = rec.anchor || '';
var site = (siteName() || '').trim();
var url = citeUrl(src.page, anchor);
var retrieved = retrievedToday();
var loc = locatorText(anchor, src.scheme);
var head, headHtml, showSite;
if (author) {
head = author + '. (n.d.). ' + title + '.';
headHtml = esc(author) + '. (n.d.). <i>' + esc(title) + '</i>.';
showSite = site && site !== author;
} else {
// Title in the author position - italicised there too, and the site
// name is dropped when it would only repeat what the title says.
head = title + '. (n.d.).';
headHtml = '<i>' + esc(title) + '</i>. (n.d.).';
showSite = site && site !== title;
}
var tail = ' Retrieved ' + retrieved + ', from ' + url;
var reference = head + (showSite ? ' ' + site + '.' : '') + tail;
var referenceHtml = headHtml + (showSite ? ' ' + esc(site) + '.' : '')
+ ' Retrieved ' + esc(retrieved) + ', from '
+ '<a href="' + esc(url) + '">' + esc(url) + '</a>';
// The in-text citation carries the locator, expanded: 1.1.3, not
// KKN_C01_S01_V03. The name is shortened the way an in-text citation
// shortens one - the library's parenthetical alias ("Śrīman
// Madhvācārya (Ānandatīrtha)") belongs in the reference, not here.
var shortName = (author || title).replace(/\s*\([^)]*\)\s*$/, '').trim();
var inText = '(' + (shortName || author || title) + ', n.d.'
+ (loc.numeric ? ', ' + loc.numeric : '') + ')';
return { reference: reference, referenceHtml: referenceHtml,
inText: inText, url: url,
locator: loc.numeric, locatorSpelled: loc.spelled };
}
// html is optional. When given, both flavours go on the clipboard so a
// paste into Word/Docs keeps the italicised title and the live link,
// while a paste into a plain field (or the address bar) gets the text.
function copyToClipboard(text, done, html) {
if (html && window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
try {
var item = new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' })
});
navigator.clipboard.write([item]).then(done).catch(function () {
plainCopy(text, done);
});
return;
} catch (e) { /* fall through */ }
}
plainCopy(text, done);
}
function plainCopy(text, done) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(function () { legacyCopy(text, done); });
} else { legacyCopy(text, done); }
}
function legacyCopy(text, done) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;';
document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); done && done(); } catch (e) {}
document.body.removeChild(ta);
}
function openBookmarkComposer() {
hideActions();
if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
$bmComposer.addClass('gra-composer-visible');
$backdrop.addClass('gra-backdrop-visible');
setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
}
function closeBookmarkComposer() {
$bmComposer.removeClass('gra-composer-visible');
$backdrop.removeClass('gra-backdrop-visible');
$bmInput.val('');
_selRange = null; _selText = ''; _selRect = null;
}
function submitBookmark() {
var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1));
// A bookmark's name is stored in the same column as a note's text and
// is capped the same way.
if (byteLength(name) > MAX_NOTE_BYTES) {
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();
// Resolve the citation anchor BEFORE wrapping: wrapSelection inserts a
// span of its own, and resolving afterwards would just find that.
var anchorEl = citeAnchorFor(_selRange && _selRange.startContainer);
var span = wrapSelection(id, 'gra-bookmark-highlight');
if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
if (!anchorEl && span) anchorEl = citeAnchorFor(span);
var rec = {id:id, name:name, quote:quote, ts:nowIso(),
anchor: anchorEl ? (anchorEl.id || anchorEl.getAttribute('data-block-id') || '') : '',
citeSource: citeSourceFor(anchorEl || document.body)};
_bookmarks.push(rec);
Store.cache();
Store.save('bookmark', rec);
renderBookmarkCards(); updateBadge();
closeBookmarkComposer();
openPanel('bookmarks');
}
function deleteBookmark(id) {
_bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
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();
}
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>');
return;
}
var html = '';
_notes.slice().reverse().forEach(function(n){
html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
+ '<div class="gra-card-header">'
+ '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>'
+ '<div class="gra-card-meta">'
+ (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
+ '</div>'
+ '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
+ '</div>'
+ (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
+ '<div class="gra-card-text">'+esc(n.text)+'</div>'
+ '</div>';
});
$paneNotes.html(html);
}
function deleteNote(id) {
_notes = _notes.filter(function(n){ return n.id !== id; });
var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-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); renderNoteCards(); updateBadge();
}
function renderBookmarkCards() {
if (!_bookmarks.length) {
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>');
return;
}
var html = '';
_bookmarks.slice().reverse().forEach(function(b){
var cite = buildCitation(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>' : '')
// One button. The reference already carries the work, the
// locator's page and the link, so a separate "copy link" only
// asked the reader to decide something they don't need to.
// The in-text form stays on the card because it is what goes
// in a sentence, but it copies on click rather than earning a
// button of its own.
+ '<div class="gra-bookmark-cite">'+cite.referenceHtml+'</div>'
+ '<div class="gra-bookmark-intext" data-cite-id="'+esc(b.id)+'"'
+ ' title="Click to copy the in-text citation">In text: '+esc(cite.inText)
+ (cite.locatorSpelled ? ' <span class="gra-cite-locator">('
+ esc(cite.locatorSpelled) + ')</span>' : '')
+ '</div>'
+ '<div class="gra-cite-actions">'
+ '<button type="button" class="gra-cite-copy" data-cite-what="reference"'
+ ' data-cite-id="'+esc(b.id)+'">Copy citation</button>'
+ '</div>'
+ '</div>'
+ '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>'
+ '</div>';
});
$paneBookmarks.html(html);
}
// NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal
// localStorage annotations, they're real saved page content (QuickEdit
// writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved
// HTML). So this reads straight off the live DOM on every open, no
// fetch, no storage — verified the scan + both-direction lookup logic
// against realistic saved-footnote HTML before wiring this in (6/6 pass).
function renderFootnoteCards() {
var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item');
if (!items.length) {
$paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>');
return;
}
// CHANGED: grouped by source. A moola page now also carries the notes
// of every commentary shown on it, and flattening them into one list
// put a commentary's notes after the moola's with nothing saying which
// was which - the cards were indistinguishable. Each note is filed
// under the heading of the document it came from: the page's own notes
// first, then one section per commentary, in the order the page shows
// them. common.js wraps injected notes in .gr-teeka-notes-group with a
// .gr-teeka-notes-title, which is where the heading comes from.
function groupOf(li) {
var wrap = li.closest ? li.closest('.gr-teeka-notes-group') : null;
if (!wrap) return null;
var t = wrap.querySelector('.gr-teeka-notes-title');
return {
slug: wrap.getAttribute('data-teeka-slug') || '',
title: t ? t.textContent.trim() : ''
};
}
var sections = []; // [{key, title, items: []}] in document order
var byKey = {};
Array.prototype.forEach.call(items, function (li) {
var g = groupOf(li);
var key = g ? g.slug : '';
if (!byKey[key]) {
byKey[key] = { key: key, title: g ? g.title : '', items: [] };
sections.push(byKey[key]);
}
byKey[key].items.push(li);
});
var html = '';
sections.forEach(function (sec) {
if (sec.title) {
html += '<div class="gra-footnote-section-title">' + esc(sec.title) + '</div>';
} else if (sections.length > 1) {
html += '<div class="gra-footnote-section-title gra-footnote-section-own">'
+ esc(pageOwnNotesLabel()) + '</div>';
}
sec.items.forEach(function (li) { html += footnoteCard(li, sec.title); });
});
$paneFootnotes.html(html);
}
// The heading for the page's own notes, shown only when there is at
// least one commentary section to tell them apart from.
function pageOwnNotesLabel() {
var t = document.querySelector('.gr-doc-title');
return (t && t.textContent.trim()) || 'This page';
}
function footnoteCard(li, groupTitle) {
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') || '';
// The commentary's name is repeated on the card itself, not only on
// the section heading: a reader who has scrolled the panel, or who
// arrives at a card by clicking a word in the text, sees the card
// without its heading in view.
return '<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">'
+ (groupTitle ? '<div class="gra-footnote-source">'+esc(groupTitle)+'</div>' : '')
+ '<div class="gra-bookmark-name">'+esc(primary)+'</div>'
+ (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '')
+ '</div>'
+ '</div>';
}
function scrollToHighlight(id) {
var el = document.querySelector('[data-gra-id="'+id+'"]');
if (!el) return;
el.scrollIntoView({behavior:'smooth', block:'center'});
el.classList.add('gra-hl-active');
setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
}
function wireEvents() {
/* Suppress native context menu inside article content (Android/desktop) */
document.addEventListener('contextmenu', function(e) {
var tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
var c = document.querySelector(CONTENT_SEL);
if (c && c.contains(e.target)) e.preventDefault();
}, { passive: false });
/* Desktop mouseup */
$(document).on('mouseup', function(e){
if (e.button !== 0) return;
if (_mobile) return;
setTimeout(tryShowActions, 20);
});
/* Separate timers so mobile + desktop never clobber each other */
var _selTimer = null; /* desktop debounce */
var _mobShowTimer = null; /* mobile show-on-touchend */
/* Mobile: show fab quickly after finger lifts (selection settled).
180ms feels instant while still letting the range stabilise. */
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 });
/* Mobile: only HIDE the fab when selection is cleared while it's visible.
(Reposition isn't needed now that the bar is docked, and re-running
showFab here was causing the lag/flicker.) */
document.addEventListener('selectionchange', function() {
if (!_mobile) return;
if (!$fab.hasClass('gra-fab-visible')) return;
var sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
clearTimeout(_mobShowTimer);
hideActions();
}
});
/* 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);
});
/* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */
$fab[0].addEventListener('touchstart', function(e) {
_fabTouched = true;
/* Don't propagate to document handler */
e.stopPropagation();
}, { passive: true });
/* Click outside → hide actions (blocked if fab was touched) */
$(document).on('mousedown touchstart', function(e){
if (_fabTouched) { _fabTouched = false; return; }
var t = e.target;
if ($fab[0] && $fab[0].contains(t)) return;
if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
hideActions();
});
/* ── FAB buttons — use touchend for mobile, click for desktop ── */
function fabAction(btnId, action) {
var el = document.getElementById(btnId);
if (!el) return;
/* touchend: fires before document touchstart clears _selRange */
el.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation();
if (!_selRange && !reCaptureFromDOM()) return;
action();
}, { passive: false });
/* click: for desktop */
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!_selRange && !reCaptureFromDOM()) return;
action();
});
}
fabAction('gra-fab-note', openNoteComposer);
fabAction('gra-fab-bookmark', openBookmarkComposer);
fabAction('gra-fab-feedback', openFeedbackComposer);
document.getElementById('gra-fab-search').addEventListener('touchend', function(e) {
e.preventDefault(); e.stopPropagation();
var q = _selText;
hideActions();
_selRange = null; _selText = ''; _selRect = null;
if (q && window.showSearchDialog) { window.showSearchDialog(q); }
}, { passive: false });
document.getElementById('gra-fab-search').addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var q = _selText;
hideActions();
_selRange = null; _selText = ''; _selRect = null;
if (q && window.showSearchDialog) { window.showSearchDialog(q); }
else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
});
/* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
(function () {
var dismissEl = document.getElementById('gra-fab-dismiss');
if (!dismissEl) return;
function doDismiss(e) {
e.preventDefault(); e.stopPropagation();
hideActions();
_selRange = null; _selText = ''; _selRect = null;
if (window.getSelection) {
var s = window.getSelection();
if (s && s.removeAllRanges) s.removeAllRanges();
}
}
dismissEl.addEventListener('touchend', doDismiss, { passive: false });
dismissEl.addEventListener('click', doDismiss);
}());
/* Feedback composer */
$fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); });
$('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer);
$fbSubmit.on('click', submitFeedback);
$fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); });
/* Note composer */
$ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); });
$('#gra-nt-cancel').on('click', closeNoteComposer);
$ntSubmit.on('click', submitNote);
$ntInput.on('keydown', function(e){
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote();
if (e.key==='Escape') closeNoteComposer();
});
/* Bookmark composer */
$('#gra-bm-cancel').on('click', closeBookmarkComposer);
$bmSubmit.on('click', submitBookmark);
$bmInput.on('keydown', function(e){
if (e.key==='Enter') submitBookmark();
if (e.key==='Escape') closeBookmarkComposer();
});
/* Panel */
$('#gra-panel-close').on('click', closePanel);
$backdrop.on('click touchend', function(e){
e.preventDefault();
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
else closePanel();
});
$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;
if ($(e.target).hasClass('gra-cite-copy')) return;
if ($(e.target).closest('.gra-bookmark-intext').length) return;
var id = $(this).attr('data-gra-id');
if (id) { closePanel(); scrollToHighlight(id); }
});
$paneBookmarks.on('click', '.gra-bookmark-intext', function(e){
e.stopPropagation();
var $el = $(this), id = $el.attr('data-cite-id');
var rec = _bookmarks.filter(function(b){ return b.id === id; })[0];
if (!rec) return;
copyToClipboard(buildCitation(rec).inText, function(){
$el.addClass('gra-intext--done');
setTimeout(function(){ $el.removeClass('gra-intext--done'); }, 1200);
});
});
$paneBookmarks.on('click', '.gra-cite-copy', function(e){
e.stopPropagation();
var $btn = $(this), id = $btn.attr('data-cite-id');
var rec = _bookmarks.filter(function(b){ return b.id === id; })[0];
if (!rec) return;
var cite = buildCitation(rec);
var what = $btn.attr('data-cite-what') || 'reference';
// Only the reference carries formatting worth preserving; the in-text
// form and the bare link must stay plain so they survive a paste into
// the address bar.
var text = cite[what] || cite.reference;
var html = (what === 'reference') ? cite.referenceHtml : null;
var was = $btn.text();
copyToClipboard(text, function(){
$btn.text('Copied').addClass('gra-cite-copy--done');
setTimeout(function(){ $btn.text(was).removeClass('gra-cite-copy--done'); }, 1600);
}, html);
});
$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(){
var id = $(this).attr('data-gra-id');
openPanel('notes');
setTimeout(function(){
var $card = $paneNotes.find('[data-gra-id="'+id+'"]');
if ($card.length) {
$card.addClass('gra-card-active');
$card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
}
}, 100);
});
$(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
var id = $(this).attr('data-gra-id');
openPanel('bookmarks');
setTimeout(function(){
var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
// CHANGED: was missing the gra-card-active flash entirely (only
// notes and footnotes had it) — a separate inconsistency from
// the CSS gap below. Now matches the other two exactly.
if ($card.length) {
$card.addClass('gra-card-active');
$card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
}
}, 100);
});
// CHANGED: was .gra-qe-footnote-ref a with e.preventDefault() (it used
// to be a real link). Now a plain highlight span, same as notes/
// bookmarks — no href to prevent, id read directly via data-gra-id,
// 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');
openPanel('footnotes');
setTimeout(function(){
var $card = $paneFootnotes.find('[data-gra-id="'+id+'"]');
if ($card.length) {
$card.addClass('gra-card-active');
$card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
}
}, 100);
});
// NEW: cross-document links from QuickEdit's Link picker. No real
// href is ever saved (confirmed directly: MediaWiki's sanitizer
// escapes <a href> into visible literal text on this wiki, even for
// a full absolute URL) — the target lives in data-gr-href instead,
// and this handler is what actually performs the navigation.
$(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
});
$(document).on('keydown', function(e){
if (e.key !== 'Escape') return;
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
else closePanel();
});
}
// Re-anchors note highlights from _notes, the way bookmarks have always
// re-anchored from _bookmarks. This used to read a third store —
// 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){}
});
}
function restoreBookmarkHighlights() {
_bookmarks.forEach(function(b){
if (!b.quote) return;
if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return;
var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
if (!needle) return;
var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
if (!found) return;
var sp = document.createElement('span');
sp.className = 'gra-bookmark-highlight';
sp.setAttribute('data-gra-id', b.id);
sp.setAttribute('data-gra-name', b.name);
try { found.surroundContents(sp); } catch(e){}
});
}
function findTextInContent(root, needle) {
if (!root || !needle) return null;
var text = root.textContent || '';
var idx = text.indexOf(needle);
if (idx < 0) return null;
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
var pos = 0, node, startNode, startOffset, endNode, endOffset;
while ((node = iter.nextNode())) {
var len = node.nodeValue.length;
if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; }
var endIdx = idx + needle.length;
if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; }
pos += len;
}
if (!startNode || !endNode) return null;
try {
var r = document.createRange();
r.setStart(startNode, startOffset);
r.setEnd(endNode, endOffset);
return r;
} catch(e){ return null; }
}
// 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();
hydrateCitationSources();
// Commentary blocks arrive after the page does, so hydrate again when
// they land rather than only once at boot.
window.addEventListener('gr-new-content', function (e) {
hydrateCitationSources(e && e.detail && e.detail.container);
});
function paint() {
try { restoreNoteHighlights(); } catch(e){}
try { restoreBookmarkHighlights(); } catch(e){}
if ($panel.hasClass('gra-panel-open')) switchTab(_activeTab);
updateBadge();
}
// 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);
});
// Small count on the toggle, so a reader can tell there is something in
// the panel without opening it — worth having now that notes can arrive
// from another device.
// The count badge is gone. It sat on the round toggle, which clips its
// own overflow to stay circular, so the badge was cut off on every side
// and told the reader nothing they could act on. Kept as a no-op so the
// five call sites that report a change still read clearly.
function updateBadge() { }
}() );