MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
 
(62 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Comments + Bookmarks  (v2)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab)
* ══════════════════════════════════════════════════════════════════════
*
* CHANGES FROM v1
* ────────────────
* • Mobile support: selectionchange + touchend events trigger the FAB
*  on iOS/Android. Long-press to select text now shows the comment/
*  bookmark strip correctly.
* • Anonymous comments: any visitor (logged-in or not) can comment.
*  A name field is shown to anonymous users. Comments are stored on
*  Talk:<PageTitle>/GrComments as before and emailed to
*  feedback@anandamakaranda.in via mailto: (anon) or MW EmailUser
*  (logged-in, falls back to mailto: if email not configured).
* • Email target changed to feedback@anandamakaranda.in.
* • "Add comment" tooltip localised to "टिप्पणी".
* • "Bookmark" tooltip localised to "चिह्नांकन".
*
* BEHAVIOUR
* ──────────
* 1. User selects text anywhere in #mw-content-text (desktop mouse or
*    mobile long-press).
* 2. FAB strip (Comment / Bookmark) appears near the selection.
* 3. Clicking Comment:
*    – Opens composer. Anonymous users see a "Your name" field.
*    – On submit: wraps selection in yellow highlight, saves to wiki,
*      emails feedback@anandamakaranda.in, updates panel.
* 4. Clicking Bookmark:
*    – Opens name composer. Saves to localStorage, wraps in blue highlight.
* 5. Right panel (slide-in overlay):
*    – Tab 1: Comments  (loaded from Talk page)
*    – Tab 2: Bookmarks  (localStorage)
*
* STORAGE
* ───────
* Comments  → Talk:<PageTitle>/GrComments  ({{GrComment|…}} templates)
* Bookmarks → localStorage key grantha_bm_<pageName>
* Highlight anchors → localStorage key grantha_cmt_<pageName>
*
* NOTIFICATION
* ────────────
* Logged-in  : MW EmailUser API → feedback@anandamakaranda.in (admin account)
*              Falls back to Talk page notification + mailto: link.
* Anonymous  : Opens mailto:feedback@anandamakaranda.in in a new tab.
*              The comment is still stored on the wiki Talk page.
* ══════════════════════════════════════════════════════════════════════
  */
  */


Line 51: Line 7:
   'use strict';
   'use strict';


  // ── Configuration ────────────────────────────────────────────────────
  var FEEDBACK_EMAIL = 'feedback@anandamakaranda.in';
   var CONTENT_SEL  = '#mw-content-text';
   var CONTENT_SEL  = '#mw-content-text';
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var CMT_LS_KEY    = 'grantha_cmt_' + ( ( 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 pageTitle    = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
  var commentsPage  = 'Talk:' + pageTitle + '/GrComments';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   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 () {} );
  }


  // Run on all content namespaces except the /GrComments page itself
   if ( window.mw ) {
   if ( window.mw ) {
     var ns = mw.config.get( 'wgNamespaceNumber' );
     var ns = mw.config.get( 'wgNamespaceNumber' );
     if ( ns < 0 ) return;
     if ( ns < 0 ) return;
    if ( /\/GrComments$/.test( pageTitle ) ) return;
   }
   }


  // ── State ────────────────────────────────────────────────────────────
   var _selRange  = null;
   var _selRange  = null;
   var _selText    = '';
   var _selText    = '';
   var _selRect    = null;
   var _selRect    = null;
   var _comments  = [];
   var _notes      = [];
   var _bookmarks  = [];
   var _bookmarks  = [];
  var _cmtLoaded  = false;
   var _activeTab  = 'notes';
   var _activeTab  = 'comments';
   var _selVersion = 0;
 
   var _fabSelVer  = -1;
  // Mobile: track whether FAB was dismissed for the current selection
  var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;
   var _selVersion = 0;   // incremented on each new selectionchange
  var _fabTouched = false;  // flag to prevent hideActions when tapping fab
   var _fabSelVer  = -1;  // the _selVersion when FAB was last shown


  // ── Helpers ──────────────────────────────────────────────────────────
   function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
   function uid() {
   function esc(s) {
    return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7);
     return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;')
  }
                        .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
   function esc( s ) {
     return String( s || '' )
      .replace(/&/g,'&amp;').replace(/</g,'&lt;')
      .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }
  function nowIso() {
    return new Date().toISOString().replace(/\.\d{3}Z$/,'Z');
   }
   }
   function fmtTs( ts ) {
  function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); }
   function fmtTs(ts) {
     try {
     try {
       var d = new Date( ts );
       var d = new Date(ts);
       return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})
       return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})
           + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false});
           + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false});
     } catch(e){ return ts; }
     } catch(e){ return ts; }
   }
   }
   function clamp( v, lo, hi ) { return Math.max(lo, Math.min(hi, v)); }
   function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); }
   function isMobile() { return window.innerWidth < 768 || 'ontouchstart' in window; }
   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 : [];
    }


  // ── DOM references ───────────────────────────────────────────────────
    function fromCache() {
   var $fab, $panel, $backdrop;
      _notes = cached( NT_LS_KEY );
   var $cmpComposer, $cmpInput, $cmpSubmit, $cmpName;
      _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 $bmComposer, $bmInput, $bmSubmit;
   var $tabComments, $tabBookmarks;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $paneComments, $paneBookmarks;
   // 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
   // DOM BUILDER
   // 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() {
   function buildDom() {
    // ── FAB strip ────────────────────────────────────────────────────
     $fab = $( [
     $fab = $( [
       '<div id="gra-fab" role="toolbar" aria-label="Comment / Bookmark">',
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">',
       '  <button class="gra-fab-btn" id="gra-fab-comment" type="button" aria-label="Add comment">',
       '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Note">',
       '    <span class="gra-icon gra-icon-comment" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Comment</span>',
       '    <span class="gra-fab-btn-label">Note</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">',
       '  <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-icon gra-icon-bookmark" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Bookmark</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>',
       '  </button>',
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $fab );
     $('body').append($fab);
 
    $mobileBar = $('<div id="gra-mobile-bar"></div>');
    $('body').append($mobileBar);


     // ── Comment composer ─────────────────────────────────────────────
     $fbComposer = $( [
    // Anonymous users see an extra "Your name" field (hidden for logged-in)
      '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
    var nameRow = currentUser ? '' : [
      '  <div class="gra-composer-header">',
       '  <div class="gra-composer-name-row">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
       '   <input class="gra-composer-input gra-name-input" id="gra-cmp-name"',
      '    <strong>Feedback</strong>',
       '     type="text" placeholder="Your name (optional)" autocomplete="name">',
      '    <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>',
     ].join('');
      '  <div class="gra-fb-status" id="gra-fb-status"></div>',
      '</div>',
     ].join('') );
    $('body').append($fbComposer);


     $cmpComposer = $( [
     $ntComposer = $( [
       '<div class="gra-composer" id="gra-cmp-composer" role="dialog" aria-label="Add comment">',
       '<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">',
       '  <div class="gra-composer-user">',
       '  <div class="gra-composer-user">',
       '    <div class="gra-avatar" id="gra-cmp-avatar">' + esc(currentUser ? userInitial : '?') + '</div>',
       '    <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Guest') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Notes') + '</div>',
       '  </div>',
       '  </div>',
      nameRow,
       '  <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
      '    placeholder="Write a comment…" rows="3"></textarea>',
       '  <div class="gra-composer-actions">',
       '  <div class="gra-composer-actions">',
       '    <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>',
       '    <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>',
       '    <button class="gra-btn-submit" id="gra-cmp-submit" disabled>Comment</button>',
       '    <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>',
       '  </div>',
       '  </div>',
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $cmpComposer );
     $('body').append($ntComposer);


    // ── Bookmark composer ─────────────────────────────────────────────
     $bmComposer = $( [
     $bmComposer = $( [
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
Line 164: Line 547:
       '    Save bookmark',
       '    Save bookmark',
       '  </div>',
       '  </div>',
       '  <input class="gra-composer-input" id="gra-bm-input"',
       '  <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">',
      '    type="text" placeholder="Name this bookmark…" autocomplete="off">',
       '  <div class="gra-composer-actions">',
       '  <div class="gra-composer-actions">',
       '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
       '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
Line 172: Line 554:
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $bmComposer );
     $('body').append($bmComposer);


    // ── Right panel ───────────────────────────────────────────────────
     $panel = $( [
     $panel = $( [
       '<div id="gra-panel" role="complementary" aria-label="Comments">',
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
       '  <div id="gra-panel-head">',
       '  <div id="gra-panel-head">',
       '    <div id="gra-panel-title"></div>',
       '    <div id="gra-panel-title"></div>',
Line 182: Line 563:
       '  </div>',
       '  </div>',
       '  <div id="gra-tabs">',
       '  <div id="gra-tabs">',
       '    <button class="gra-tab gra-tab-active" id="gra-tab-comments">',
       '    <button class="gra-tab gra-tab-active" id="gra-tab-notes">',
       '      <span class="gra-icon gra-icon-comment" aria-hidden="true"></span> Comments',
       '      <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes',
       '    </button>',
       '    </button>',
       '    <button class="gra-tab" id="gra-tab-bookmarks">',
       '    <button class="gra-tab" id="gra-tab-bookmarks">',
       '      <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> 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>',
       '    </button>',
       '  </div>',
       '  </div>',
       '  <div id="gra-panel-body">',
       '  <div id="gra-panel-body">',
       '    <div class="gra-pane gra-pane-active" id="gra-pane-comments"></div>',
       '    <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-bookmarks"></div>',
      '    <div class="gra-pane" id="gra-pane-footnotes"></div>',
       '  </div>',
       '  </div>',
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $panel );
     $('body').append($panel);


     $backdrop = $( '<div id="gra-backdrop" aria-hidden="true"></div>' );
     $backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>');
     $( 'body' ).append( $backdrop );
     $('body').append($backdrop);


    // Toggle FAB (bottom-right, persistent)
     var $toggle = $( [
     var $toggle = $( [
       '<button id="gra-toggle" aria-label="Comments">',
       '<button id="gra-toggle" aria-label="Notes">',
       '  <span class="gra-icon gra-icon-comment" id="gra-toggle-icon" aria-hidden="true"></span>',
       '  <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>',
      '  <span id="gra-toggle-badge" aria-live="polite"></span>',
       '</button>',
       '</button>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $toggle );
     $('body').append($toggle);
     $toggle.on( 'click', function() {
     $toggle.on('click', function() {
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel( _activeTab );
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
     } );
     });


    // Cache
     $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
     $( '#gra-panel-title' )
     $tabNotes    = $('#gra-tab-notes');
      .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) );
     $tabBookmarks = $('#gra-tab-bookmarks');
     $tabComments  = $( '#gra-tab-comments' );
     $tabFootnotes = $('#gra-tab-footnotes');
     $tabBookmarks = $( '#gra-tab-bookmarks' );
    $paneNotes    = $('#gra-pane-notes');
     $paneComments = $( '#gra-pane-comments' );
     $paneBookmarks= $('#gra-pane-bookmarks');
     $paneBookmarks= $( '#gra-pane-bookmarks' );
     $paneFootnotes= $('#gra-pane-footnotes');
     $cmpInput     = $( '#gra-cmp-input' );
     $ntInput      = $('#gra-nt-input');
     $cmpSubmit    = $( '#gra-cmp-submit' );
     $ntSubmit    = $('#gra-nt-submit');
     $cmpName     = $( '#gra-cmp-name' );   // may be empty set for logged-in users
     $bmInput     = $('#gra-bm-input');
     $bmInput     = $( '#gra-bm-input' );
    $bmSubmit    = $('#gra-bm-submit');
     $bmSubmit     = $( '#gra-bm-submit' );
    $fbIssueType  = $('#gra-fb-issue');
    $fbText      = $('#gra-fb-text');
     $fbEmail     = $('#gra-fb-email');
     $fbSubmit     = $('#gra-fb-submit');
    $fbQuote      = $('#gra-fb-quote');
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // SELECTION — desktop + mobile
  // ════════════════════════════════════════════════════════════════════


   function captureSelection() {
   function captureSelection() {
     var sel = window.getSelection();
     var sel = window.getSelection();
     if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
     if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
     var range = sel.getRangeAt(0);
     var range = sel.getRangeAt(0);
     var text  = sel.toString().trim();
     var text  = sel.toString().trim();
     if ( !text || text.length < 2 ) return false;
     if (!text || text.length < 2) return false;
 
     var contentEl = document.querySelector(CONTENT_SEL);
     var contentEl = document.querySelector( CONTENT_SEL );
     if (!contentEl) return false;
     if ( !contentEl ) return false;
     var ancestor = range.commonAncestorContainer;
     var start = range.commonAncestorContainer;
     if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
     if ( start.nodeType === 3 ) start = start.parentNode;
     if (!ancestor || !contentEl.contains(ancestor)) return false;
     if ( !contentEl.contains( start ) ) 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;
     _selText  = text;
    _selRange = range.cloneRange();
     _selRect  = range.getBoundingClientRect();
     _selRect  = range.getBoundingClientRect();
    try { _selRange = range.cloneRange(); }
    catch(e){ _selRange = null; }
     return true;
     return true;
   }
   }


   function tryShowFab() {
   function reCaptureFromDOM() {
     if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
     if (!_selText) return false;
     if ( $bmComposer.hasClass('gra-composer-visible') ) return;
    var contentEl = document.querySelector(CONTENT_SEL);
     if ( captureSelection() ) {
    if (!contentEl) return false;
      _fabSelVer = _selVersion;
     var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim());
      showFab( _selRect );
     if (!found) return false;
     } else {
    _selRange = found;
      hideFab();
     return true;
    }
   }
   }


   // ════════════════════════════════════════════════════════════════════
   function tryShowActions() {
  // FAB POSITIONING
    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 ) {
   function showFab(rect) {
     if ( !rect ) return;
     if (!rect) return;
     var fabW = 46, fabH = 84;
     var fabW, fabH, top, left;
     var top  = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
    if (_mobile) {
     var left = rect.right + 10;
      $fab.css({ position: '', top: '', left: '', visibility: '' })
     if ( left + fabW > window.innerWidth - 8 ) left = rect.left - fabW - 10;
          .addClass('gra-fab-visible gra-fab-mobile-docked');
     top  = clamp( top,  8, window.innerHeight - fabH - 8 );
      return;
     left = clamp( left, 8, window.innerWidth  - fabW - 8 );
    }
    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');
     $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
   }
   }


   function hideFab() { $fab.removeClass('gra-fab-visible'); }
   function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
 
   function hideActions() { hideFab(); }
  // ════════════════════════════════════════════════════════════════════
  // COMPOSER POSITIONING
  // ════════════════════════════════════════════════════════════════════
 
   function positionComposer( $el ) {
    if ( !_selRect ) return;
    var W  = 308;
    var top  = _selRect.bottom + 8;
    var left = _selRect.left;
    if ( left + W > window.innerWidth - 8 ) left = window.innerWidth - W - 8;
    left = Math.max( left, 8 );
    if ( top + 200 > window.innerHeight ) top = _selRect.top - 210;
    top  = Math.max( top, 8 );
    // On very small screens center it
    if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2;
    $el.css({ top: top + 'px', left: left + 'px' });
  }
 
  // ════════════════════════════════════════════════════════════════════
  // WRAP SELECTION
  // ════════════════════════════════════════════════════════════════════


   function wrapSelection( id, cssClass ) {
   function wrapSelection(id, cssClass) {
    if ( !_selRange ) return null;
     var range = _selRange;
     var range = _selRange;
     _selRange = null;
     _selRange = null;
    if (!range) return null;
     try {
     try {
       var span = document.createElement('span');
       if (!document.contains(range.startContainer) ||
      span.className = cssClass;
          !document.contains(range.endContainer)) return null;
      span.setAttribute('data-gra-id', id);
     } catch(e) { return null; }
      range.surroundContents( span );
    function makeSpan() {
      return span;
      var sp = document.createElement('span');
     } catch (e) {
      sp.className = cssClass;
      try {
      sp.setAttribute('data-gra-id', id);
        var frag = range.extractContents();
       return sp;
        var sp2  = document.createElement('span');
        sp2.className = cssClass;
        sp2.setAttribute('data-gra-id', id);
        sp2.appendChild(frag);
        range.insertNode(sp2);
        return sp2;
       } catch(e2) { return null; }
     }
     }
    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() {
  // COMMENT FLOW
    $fbComposer.removeClass('gra-composer-visible');
   // ════════════════════════════════════════════════════════════════════
    $backdrop.removeClass('gra-backdrop-visible');
    _selRange = null; _selText = ''; _selRect = null;
   }


   function openCommentComposer() {
   function submitFeedback() {
     hideFab();
     var issueType = $fbIssueType.val();
     positionComposer( $cmpComposer );
    var details  = $fbText.val().trim();
     $cmpComposer.addClass('gra-composer-visible');
     var email    = $fbEmail.val().trim();
     // On mobile, delay focus so keyboard doesn't displace the composer
    var quote    = $fbQuote.text();
     setTimeout( function() { $cmpInput.focus(); }, isMobile() ? 300 : 0 );
    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 closeCommentComposer() {
   function showFeedbackSuccess() {
     $cmpComposer.removeClass('gra-composer-visible');
     $fbSubmit.prop('disabled', false).text('Send');
     $cmpInput.val('');
     $('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok');
    if ( $cmpName.length ) $cmpName.val('');
    setTimeout(closeFeedbackComposer, 2500);
     $cmpSubmit.prop('disabled', true);
  }
     _selRange = null; _selText = ''; _selRect = null;
  function showFeedbackError(msg) {
     $fbSubmit.prop('disabled', false).text('Send');
     $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
   }
   }


   function submitComment() {
   function openNoteComposer() {
     var text = $cmpInput.val().trim();
     hideActions();
     if ( !text ) return;
    if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' });
    $ntComposer.addClass('gra-composer-visible');
    $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
  }


     // Resolve author name: logged-in user or anonymous name or "अतिथि"
  function closeNoteComposer() {
     var author = currentUser
     $ntComposer.removeClass('gra-composer-visible');
      || ( $cmpName.length ? $cmpName.val().trim() : '' )
     $backdrop.removeClass('gra-backdrop-visible');
      || 'Guest';
    $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 id    = uid();
     var ts    = nowIso();
     var ts    = nowIso();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     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');
  }


    var span = wrapSelection( id, 'gra-comment-highlight' );
    if ( span ) span.setAttribute('data-gra-quote', quote);


    var entry = { id:id, author:author, ts:ts, quote:quote, text:text };
  /* ── SHAREABLE CITATION ────────────────────────────────────────────
    _comments.push( entry );
    A bookmark is personal, but what it produces is not: a reference in
    persistCommentHighlight( id, quote );
    APA form plus a link that resolves for anyone.
    saveCommentToWiki( id, author, quote, text, ts, id );
    notifyByEmail( id, author, quote, text, ts );


    renderCommentCards();
    The link cannot point at the bookmark itself. A bookmark wraps the
    closeCommentComposer();
    reader's own selection in a span with an id generated in their
     openPanel('comments');
    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;
   }
   }


   function saveCommentToWiki( id, author, quote, text, ts, anchorId ) {
   // Walks up from the cited block: an interleaved commentary block states
    if ( !window.mw ) return;
  // its own work, otherwise the page's title element does.
     var api = new mw.Api();
  // A reference written in English cites the work in IAST, so prefer the
     api.get({
  // transliterated forms the library supplies and keep Devanagari as the
       action:'query', prop:'revisions', titles:commentsPage,
  // fallback. common.js writes the library's values onto .gr-doc-title as
      rvprop:'content', rvslots:'main', formatversion:2,
  // soon as they arrive, so reading the DOM here always gets the current
    }).then(function(data){
  // names without this module knowing the library exists.
      var page = ((data.query && data.query.pages)||[])[0]||{};
  function citeSourceFor(el) {
      var existing = '';
     var n = el;
      if (page.revisions && page.revisions[0]) {
     while (n && n !== document.body) {
        var rev = page.revisions[0];
       if (n.getAttribute && n.getAttribute('data-cite-title')) {
        existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
        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') || ''
        };
       }
       }
       var entry = '{{GrComment\n'
       n = n.parentNode;
                + '| id        = ' + id  + '\n'
    }
                + '| author   = ' + author + '\n'
    var t = document.querySelector('.gr-doc-title');
                + '| timestamp = ' + ts  + '\n'
    return {
                + '| quote    = ' + quote.replace(/\n/g,' ') + '\n'
      author: (t && (t.getAttribute('data-author-iast') ||
                + '| text      = ' + text.replace(/\n/g,' ') + '\n'
                    t.getAttribute('data-author'))) || '',
                + '}}\n';
      title:  (t && (t.getAttribute('data-doc-title-iast') ||
      var updated = (existing.trim() ? existing.trim() + '\n\n' : '') + entry;
                    t.getAttribute('data-doc-title') ||
       api.postWithEditToken({
                    t.textContent.trim())) || document.title,
        action:'edit', title:commentsPage, text:updated,
      page:  (window.mw && mw.config.get('wgPageName')) || '',
        summary:'Comment — ' + author,
       scheme: (t && t.getAttribute('data-locator-scheme')) || ''
        bot:0,
    };
      }).then(function(){
  }
        // Silently notify admin by appending a compact notice to their Talk page.
 
        // MediaWiki Echo picks this up and sends an email to whoever watches that page.
  // Any block carrying a library id gets its author/title filled in from
        // This is completely invisible to the commenter — no browser hijack.
  // the library, whether common.js injected it or the page was built with
        var adminTalk = 'User_talk:Chandrashekars';
  // it already in place. Doing it here rather than only at injection time
        var artPath  = (mw.config.get('wgArticlePath')||'/wiki/$1');
  // means a citation never depends on which path put the block on screen.
        var link      = window.location.origin
  function hydrateCitationSources(root) {
                      + artPath.replace('$1', pageTitle)
    if (!window.grLibrary) return;
                      + (anchorId ? '#' + anchorId : '');
    var nodes = (root || document).querySelectorAll('[data-cite-grantha-id]');
        var notice = '\n<!-- grantha-comment-notify -->\n'
    Array.prototype.forEach.call(nodes, function (el) {
                  + '; [[' + pageTitle.replace(/_/g,' ') + ']] — ' + author + '\n'
      if (el.getAttribute('data-cite-hydrated')) return;
                  + ': ' + quote.slice(0,80) + '\n'
      var gid = el.getAttribute('data-cite-grantha-id');
                  + ': ' + link + '\n';
      if (!gid) return;
         new mw.Api().postWithEditToken({
      el.setAttribute('data-cite-hydrated', '1');
          action:'edit', title:adminTalk, section:'new',
      window.grLibrary.lookup(gid).then(function (g) {
          sectiontitle:'New comment on ' + pageTitle.replace(/_/g,' '),
        if (!g) return;
          text:notice,
         if (g.name)       el.setAttribute('data-cite-title', g.name);
          summary:'Comment notification',
        if (g.nameIAST)  el.setAttribute('data-cite-title-iast', g.nameIAST);
          bot:1,  /* bot=1 suppresses Echo "someone edited your talk page" popup
        if (g.author)     el.setAttribute('data-cite-author', g.author);
                      but still triggers email watchlist notifications */
         if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST);
         }).catch(function(){});   /* silent — never block the comment flow */
       });
       }).catch(function(){});
     });
     }).catch(function(){});
   }
   }


   function notifyByEmail( anchorId, author, quote, text, ts ) {
   // ── LOCATOR ─────────────────────────────────────────────────────────
    /* Notification is handled server-side by MediaWiki's Echo extension
  // "KKN_C01_S01_V03" is a build artefact, not a reference. A reader
    * when saveCommentToWiki() edits the Talk page — no client-side
  // citing it wants 1.1.3, and ideally to be told that those numbers are
    * mailto needed. A mailto would abruptly open the user's mail app
  // adhyaya, valli and mantra. The page declares the division names it
    * mid-session which breaks the commenting flow entirely. */
  // 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;
   }
   }


   // ── Load + parse comments ────────────────────────────────────────────
   // Segments that qualify the passage rather than number it: _B01 is the
   function loadComments( cb ) {
  // first paragraph of the bhashya ON that verse, and a bare letter with
     if ( _cmtLoaded ) { if (cb) cb(); return; }
  // no number (Katha's "_I_" for an introduction) marks a position, not a
     if ( !window.mw ) { _cmtLoaded = true; if (cb) cb(); return; }
  // division. A NUMBERED segment is always part of the path - in
     new mw.Api().get({
  // RG_C01_I01_V01 the I is a sukta, and reading it as Katha's
       action:'query', prop:'revisions', titles:commentsPage,
  // introduction marker turned 1.1.1 into "1.1, Sukta 1".
       rvprop:'content', rvslots:'main', formatversion:2,
  function isQualifier(k, n) { return k === 'B' || !n; }
     }).then(function(data){
 
      var page = ((data.query && data.query.pages)||[])[0]||{};
   function parseLocator(id) {
      var wt = '';
     if (!id) return null;
       if (page.revisions && page.revisions[0]) {
    var segs = String(id).split('_');
        var rev = page.revisions[0];
    var path = [], qual = [];
        wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
    for (var i = 1; i < segs.length; i++) {
       }
      var m = /^([A-Za-z]+)(\d*)$/.exec(segs[i]);
      _comments = parseCommentsWt(wt);
      if (!m) continue;
      _cmtLoaded = true;
      var k = m[1].toUpperCase(), n = m[2] ? String(parseInt(m[2], 10)) : '';
      if (cb) cb();
      (isQualifier(k, n) ? qual : path).push({ k: k, n: n });
     }).catch(function(){ _cmtLoaded = true; if (cb) cb(); });
    }
     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
  // APA 7 asks for a retrieval date only when the content is designed to
  // change and is not archived. These are settled texts with a fixed
  // structure, so the date is omitted. Set true to restore it.
  var SHOW_RETRIEVAL_DATE = false;
 
  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.
  // The library records names with their honorifics and, often, a
  // parenthetical alias: "Śrīman Madhvācārya (Ānandatīrtha)". A reference
  // entry carries the name a reader would look up, so the honorific and
  // the alias come off - leaving "Madhvācārya". Only applied to the
  // romanised form; the Devanagari is left exactly as the library has it,
  // because there the honorific is fused to the name by sandhi
  // (श्रीमत् + आनन्दतीर्थः -> श्रीमदानन्दतीर्थः) and cannot be sliced off safely.
  var HONORIFIC_RE = /^(?:\u015br\u012b|\u015br\u012bman|\u015br\u012bmat|\u015br\u012bmad|sri|shri|sree)\s+/i;
  function citationAuthor(name) {
    var n = (name || '').trim();
    if (!/[A-Za-z]/.test(n)) return n;            // Devanagari - leave alone
    n = n.replace(/\s*\([^)]*\)\s*$/, '').trim();
    for (var i = 0; i < 3 && HONORIFIC_RE.test(n); i++) {
      n = n.replace(HONORIFIC_RE, '').trim();
    }
    return n || (name || '').trim();
   }
   }


   function parseCommentsWt( wt ) {
   function buildCitation(rec) {
     var out = [], re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g, m;
     var src    = rec.citeSource || {};
     while ((m = re.exec(wt)) !== null) {
    var author = citationAuthor(src.author);
      var block = m[1], f = {}, lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g, lm;
     var title  = (src.title  || '').trim();
       while ((lm = lr.exec(block)) !== null) f[lm[1].trim()] = lm[2].trim();
    var anchor = rec.anchor || '';
       if (f.id) out.push({ id:f.id, author:f.author||'अतिथि', ts:f.timestamp||'', quote:f.quote||'', text:f.text||'' });
    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;
     }
     }
     return out;
 
    // APA 7 (section 9.16) asks for a retrieval date only when the content
    // is designed to change and is not archived. A wiki page can change, so
    // the date is defensible - but these are settled texts, and a reader may
    // reasonably prefer the shorter form. Set SHOW_RETRIEVAL_DATE to false
    // for: Author. (n.d.). Title. Site. URL
    var tail = SHOW_RETRIEVAL_DATE
        ? ' Retrieved ' + retrieved + ', from ' + url
        : ' ' + url;
    var reference = head + (showSite ? ' ' + site + '.' : '') + tail;
    var referenceHtml = headHtml + (showSite ? ' ' + esc(site) + '.' : '') + ' '
        + (SHOW_RETRIEVAL_DATE ? '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
   // BOOKMARK FLOW
   // 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() {
   function openBookmarkComposer() {
     hideFab();
     hideActions();
     positionComposer( $bmComposer );
     if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
     $bmComposer.addClass('gra-composer-visible');
     $bmComposer.addClass('gra-composer-visible');
     setTimeout( function() { $bmInput.focus(); }, isMobile() ? 300 : 0 );
    $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
   }
   }


   function closeBookmarkComposer() {
   function closeBookmarkComposer() {
     $bmComposer.removeClass('gra-composer-visible');
     $bmComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
     $bmInput.val('');
     $bmInput.val('');
     _selRange = null; _selText = ''; _selRect = null;
     _selRange = null; _selText = ''; _selRect = null;
Line 474: Line 1,120:


   function submitBookmark() {
   function submitBookmark() {
     var name  = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length + 1));
     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 id    = uid();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     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');
     var span  = wrapSelection(id, 'gra-bookmark-highlight');
     if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
     if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
     _bookmarks.push({ id:id, name:name, quote:quote, ts:nowIso() });
     if (!anchorEl && span) anchorEl = citeAnchorFor(span);
     persistBookmarks();
    var rec = {id:id, name:name, quote:quote, ts:nowIso(),
     renderBookmarkCards();
              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();
     closeBookmarkComposer();
     openPanel('bookmarks');
     openPanel('bookmarks');
   }
   }


   function deleteBookmark( id ) {
   function deleteBookmark(id) {
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     if (span) {
     if (span && span.parentNode) {
       var parent = span.parentNode;
       var p = span.parentNode;
       while (span.firstChild) parent.insertBefore(span.firstChild, span);
       while (span.firstChild) p.insertBefore(span.firstChild, span);
       parent.removeChild(span);
       p.removeChild(span);
     }
     }
     persistBookmarks(); renderBookmarkCards();
     Store.cache(); Store.remove(id); renderBookmarkCards(); updateBadge();
  }
 
  function persistBookmarks() {
    try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){}
  }
  function loadBookmarks() {
    try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // PANEL
  // ════════════════════════════════════════════════════════════════════


   function openPanel(tab) {
   function openPanel(tab) {
Line 518: Line 1,168:
     $backdrop.removeClass('gra-backdrop-visible');
     $backdrop.removeClass('gra-backdrop-visible');
   }
   }
  // CHANGED: added the footnotes case, alongside notes/bookmarks.
   function switchTab(tab) {
   function switchTab(tab) {
     _activeTab = tab;
     _activeTab = tab;
     $tabComments.toggleClass('gra-tab-active', tab==='comments');
     $tabNotes.toggleClass('gra-tab-active', tab==='notes');
     $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
     $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
     $paneComments.toggleClass('gra-pane-active', tab==='comments');
     $tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes');
    $paneNotes.toggleClass('gra-pane-active', tab==='notes');
     $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
     $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
     if (tab==='comments') loadComments(function(){ renderCommentCards(); });
    $paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes');
     else renderBookmarkCards();
     if (tab==='notes') renderNoteCards();
    else if (tab==='bookmarks') renderBookmarkCards();
     else renderFootnoteCards();
   }
   }


  // ════════════════════════════════════════════════════════════════════
   function renderNoteCards() {
  // RENDER CARDS
     if (!_notes.length) {
  // ════════════════════════════════════════════════════════════════════
       $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>');
 
   function renderCommentCards() {
     if (!_comments.length) {
       $paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>');
       return;
       return;
     }
     }
     var html = '';
     var html = '';
     _comments.slice().reverse().forEach(function(c){
     _notes.slice().reverse().forEach(function(n){
       html += '<div class="gra-comment-card" data-gra-id="' + esc(c.id) + '">'
       html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
             + '<div class="gra-card-header">'
             + '<div class="gra-card-header">'
             + '<div class="gra-avatar">' + esc((c.author||'?').charAt(0).toUpperCase()) + '</div>'
             + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>'
             + '<div class="gra-card-meta">'
             + '<div class="gra-card-meta">'
             + '<div class="gra-card-author">' + esc(c.author) + '</div>'
             + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
             + (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
             + '</div>'
             + '</div></div>'
            + '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
             + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
             + '</div>'
             + '<div class="gra-card-text">' + esc(c.text) + '</div>'
             + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
             + '<div class="gra-card-text">'+esc(n.text)+'</div>'
             + '</div>';
             + '</div>';
     });
     });
     $paneComments.html(html);
     $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() {
   function renderBookmarkCards() {
     if (!_bookmarks.length) {
     if (!_bookmarks.length) {
       $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save one.</div>');
       $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>');
       return;
       return;
     }
     }
     var html = '';
     var html = '';
     _bookmarks.slice().reverse().forEach(function(b){
     _bookmarks.slice().reverse().forEach(function(b){
       html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">'
      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>'
             + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>'
             + '<div class="gra-bookmark-info">'
             + '<div class="gra-bookmark-info">'
             + '<div class="gra-bookmark-name">' + esc(b.name) + '</div>'
             + '<div class="gra-bookmark-name">'+esc(b.name)+'</div>'
             + (b.quote ? '<div class="gra-bookmark-quote">' + esc(b.quote) + '</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.
            // Where in the text this sits, spelled out - "Adhyaya 3,
            // Mantra 4" rather than MUN_C03_V04. Not part of the citation
            // (APA keeps a locator out of the reference entry), so it is
            // set apart as a caption and is not included in the copy.
            + (cite.locatorSpelled || cite.locator
                ? '<div class="gra-bookmark-locator">'
                    + esc(cite.locatorSpelled || cite.locator) + '</div>'
                : '')
            + '<div class="gra-bookmark-cite">'+cite.referenceHtml+'</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>'
             + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>'
             + '</div>';
             + '</div>';
     });
     });
Line 572: Line 1,254:
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal
   // SCROLL TO HIGHLIGHT
  // 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) {
   function scrollToHighlight(id) {
     var el = document.querySelector('[data-gra-id="' + id + '"]');
     var el = document.querySelector('[data-gra-id="'+id+'"]');
     if (!el) return;
     if (!el) return;
     el.scrollIntoView({ behavior:'smooth', block:'center' });
     el.scrollIntoView({behavior:'smooth', block:'center'});
     el.classList.add('gra-hl-active');
     el.classList.add('gra-hl-active');
     setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
     setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
   }
   }


   // ════════════════════════════════════════════════════════════════════
   function wireEvents() {
  // EVENT WIRING (desktop + mobile)
 
  // ════════════════════════════════════════════════════════════════════
    /* 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 */


  function wireEvents() {
    /* 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 });


     // ── Desktop: mouseup / keyup ──────────────────────────────────
     /* Mobile: only HIDE the fab when selection is cleared while it's visible.
     $( document ).on('mouseup keyup', function(e){
      (Reposition isn't needed now that the bar is docked, and re-running
       if ( e.type === 'mouseup' && e.button !== 0 ) return; // left-click only
      showFab here was causing the lag/flicker.) */
      setTimeout( tryShowFab, 20 );
     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();
      }
     });
     });


     // ── Mobile: selectionchange (iOS + Android) ───────────────────
     /* selectionchange debounced (desktop only) */
    // 'selectionchange' fires continuously while user drags selection handles.
    // We debounce it and only act after the user has stopped for 400ms.
    var _selChangeTimer = null;
     document.addEventListener('selectionchange', function() {
     document.addEventListener('selectionchange', function() {
      if (_mobile) return;
       _selVersion++;
       _selVersion++;
       clearTimeout( _selChangeTimer );
       clearTimeout(_selTimer);
       var v = _selVersion;
       var v = _selVersion;
       _selChangeTimer = setTimeout(function(){
       _selTimer = setTimeout(function(){
        // Only trigger if this is still the latest selectionchange
         if (v !== _selVersion) return;
         if ( v !== _selVersion ) return;
         if (_fabSelVer === v) return;
         if ( _fabSelVer === v ) return; // already showed FAB for this selection
         tryShowActions();
         tryShowFab();
       }, 600);
       }, 400);
     });
     });


     // ── Mobile: touchend fallback (catch tap-to-end-selection) ────
     /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */
     document.addEventListener('touchend', function(e){
     $fab[0].addEventListener('touchstart', function(e) {
       // Don't fire if tapping the FAB itself
      _fabTouched = true;
      if ( $fab[0] && $fab[0].contains(e.target) ) return;
       /* Don't propagate to document handler */
       if ( $cmpComposer[0] && $cmpComposer[0].contains(e.target) ) return;
       e.stopPropagation();
      if ( $bmComposer[0]  && $bmComposer[0].contains(e.target)  ) return;
      // Small delay — browser settles selection after touchend
      setTimeout( tryShowFab, 80 );
     }, { passive: true });
     }, { passive: true });


     // ── Click outside → hide FAB ──────────────────────────────────
     /* Click outside → hide actions (blocked if fab was touched) */
     $( document ).on('mousedown touchstart', function(e){
     $(document).on('mousedown touchstart', function(e){
      if (_fabTouched) { _fabTouched = false; return; }
       var t = e.target;
       var t = e.target;
       if ( $fab[0] && $fab[0].contains(t) ) return;
       if ($fab[0]       && $fab[0].contains(t))       return;
       if ( $cmpComposer[0] && $cmpComposer[0].contains(t) ) return;
       if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
       if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return;
      if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
       hideFab();
       if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
       hideActions();
     });
     });


     // ── FAB buttons ───────────────────────────────────────────────
     /* ── FAB buttons — use touchend for mobile, click for desktop ── */
     $( '#gra-fab-comment' ).on('click touchend', function(e){
    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();
       e.preventDefault(); e.stopPropagation();
       // Re-capture in case selection was cleared by the tap
       var q = _selText;
       if ( !_selRange && !captureSelection() ) return;
      hideActions();
      openCommentComposer();
      _selRange = null; _selText = ''; _selRect = null;
     });
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
     $( '#gra-fab-bookmark' ).on('click touchend', function(e){
     }, { passive: false });
     document.getElementById('gra-fab-search').addEventListener('click', function(e) {
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if ( !_selRange && !captureSelection() ) return;
      var q = _selText;
       openBookmarkComposer();
      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})); }
     });
     });


     // ── Comment composer ──────────────────────────────────────────
     /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
     $cmpInput.on('input', function(){
    (function () {
      $cmpSubmit.prop('disabled', !$( this ).val().trim());
      var dismissEl = document.getElementById('gra-fab-dismiss');
    });
      if (!dismissEl) return;
     $( '#gra-cmp-cancel' ).on('click', function(){ closeCommentComposer(); hideFab(); });
      function doDismiss(e) {
     $cmpSubmit.on('click', submitComment);
        e.preventDefault(); e.stopPropagation();
     $cmpInput.on('keydown', function(e){
        hideActions();
       if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitComment();
        _selRange = null; _selText = ''; _selRect = null;
       if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
        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 ─────────────────────────────────────────
     /* Bookmark composer */
     $( '#gra-bm-cancel' ).on('click', function(){ closeBookmarkComposer(); hideFab(); });
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
     $bmSubmit.on('click', submitBookmark);
     $bmSubmit.on('click', submitBookmark);
     $bmInput.on('keydown', function(e){
     $bmInput.on('keydown', function(e){
       if (e.key==='Enter') submitBookmark();
       if (e.key==='Enter') submitBookmark();
       if (e.key==='Escape') { closeBookmarkComposer(); hideFab(); }
       if (e.key==='Escape') closeBookmarkComposer();
     });
     });


     // ── Panel ─────────────────────────────────────────────────────
     /* Panel */
     $( '#gra-panel-close' ).on('click', closePanel);
     $('#gra-panel-close').on('click', closePanel);
     $backdrop.on('click touchstart', closePanel);
     $backdrop.on('click touchend', function(e){
     $tabComments.on('click', function(){ switchTab('comments'); });
      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'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
    // NEW: third tab.
    $tabFootnotes.on('click', function(){ switchTab('footnotes'); });


    // Click comment card → scroll to highlight
     $paneNotes.on('click', '.gra-note-card', function(e){
     $paneComments.on('click', '.gra-comment-card', function(){
      if ($(e.target).hasClass('gra-note-del')) return;
       var id = $( this ).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       if (id) { closePanel(); scrollToHighlight(id); }
       if (id) { closePanel(); scrollToHighlight(id); }
     });
     });
     // Click bookmark card → scroll
     $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){
     $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
       if ($( e.target ).hasClass('gra-bookmark-del')) return;
       if ($(e.target).hasClass('gra-bookmark-del')) return;
       var id = $( this ).attr('data-gra-id');
      if ($(e.target).hasClass('gra-cite-copy')) return;
       var id = $(this).attr('data-gra-id');
       if (id) { closePanel(); scrollToHighlight(id); }
       if (id) { closePanel(); scrollToHighlight(id); }
     });
     });
     // Delete bookmark
     $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){
     $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
       e.stopPropagation();
       e.stopPropagation();
       var id = $( this ).attr('data-del-id');
       var id = $(this).attr('data-del-id');
       if (id) deleteBookmark(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); }
     });
     });


    // Highlight in text → open panel
     $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
     $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
       var id = $(this).attr('data-gra-id');
       var id = $( this ).attr('data-gra-id');
       openPanel('notes');
       openPanel('comments');
       setTimeout(function(){
       setTimeout(function(){
         var $card = $paneComments.find('[data-gra-id="'+id+'"]');
         var $card = $paneNotes.find('[data-gra-id="'+id+'"]');
         if ($card.length) {
         if ($card.length) {
           $card.addClass('gra-card-active');
           $card.addClass('gra-card-active');
Line 700: Line 1,590:
       }, 100);
       }, 100);
     });
     });
     $( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){
     $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
       var id = $( this ).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       openPanel('bookmarks');
       openPanel('bookmarks');
       setTimeout(function(){
       setTimeout(function(){
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
         if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
        // 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);
       }, 100);
     });
     });


     // Escape
     // NEW: cross-document links from QuickEdit's Link picker. No real
     $( document ).on('keydown', function(e){
    // 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 (e.key !== 'Escape') return;
       if ($cmpComposer.hasClass('gra-composer-visible')) { closeCommentComposer(); hideFab(); }
       if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
       else if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); }
      else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
       else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
       else closePanel();
       else closePanel();
     });
     });
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // Re-anchors note highlights from _notes, the way bookmarks have always
   // RESTORE HIGHLIGHTS on page reload
   // 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
  function persistCommentHighlight(id, quote) {
  // server. The old key is left alone rather than deleted; it is simply no
    try {
   // longer consulted.
      var s = JSON.parse(localStorage.getItem(CMT_LS_KEY)||'[]');
   function restoreNoteHighlights() {
      s = s.filter(function(h){ return h.id !== id; });
     _notes.forEach(function(h){
      s.push({id:id, quote:quote});
      localStorage.setItem(CMT_LS_KEY, JSON.stringify(s));
    } catch(e){}
   }
 
   function restoreCommentHighlights() {
    var s = [];
    try { s = JSON.parse(localStorage.getItem(CMT_LS_KEY)||'[]'); } catch(e){}
     s.forEach(function(h){
       if (!h.quote || !h.id) return;
       if (!h.quote || !h.id) return;
       if (document.querySelector('[data-gra-id="'+h.id+'"].gra-comment-highlight')) return;
       if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return;
       var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
       var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
       if (!needle) return;
       if (!needle) return;
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       if (range) {
       if (!range) return;
        var sp = document.createElement('span');
      var sp = document.createElement('span');
        sp.className = 'gra-comment-highlight';
      sp.className = 'gra-note-highlight';
        sp.setAttribute('data-gra-id', h.id);
      sp.setAttribute('data-gra-id', h.id);
        try { range.surroundContents(sp); } catch(e){}
      try { range.surroundContents(sp); } catch(e){}
      }
     });
     });
   }
   }
Line 756: Line 1,671:
       if (!needle) return;
       if (!needle) return;
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       if (found) {
       if (!found) return;
        var sp = document.createElement('span');
      var sp = document.createElement('span');
        sp.className = 'gra-bookmark-highlight';
      sp.className = 'gra-bookmark-highlight';
        sp.setAttribute('data-gra-id', b.id);
      sp.setAttribute('data-gra-id', b.id);
        sp.setAttribute('data-gra-name', b.name);
      sp.setAttribute('data-gra-name', b.name);
        try { found.surroundContents(sp); } catch(e){}
      try { found.surroundContents(sp); } catch(e){}
      }
     });
     });
   }
   }


   function findTextInContent(root, needle) {
   function findTextInContent(root, needle) {
     if (!root) return null;
     if (!root || !needle) return null;
     var text = root.textContent || '';
     var text = root.textContent || '';
     var idx  = text.indexOf(needle);
     var idx  = text.indexOf(needle);
Line 781: Line 1,695:
     }
     }
     if (!startNode || !endNode) return null;
     if (!startNode || !endNode) return null;
     var r = document.createRange();
     try {
    r.setStart(startNode, startOffset);
      var r = document.createRange();
    r.setEnd(endNode, endOffset);
      r.setStart(startNode, startOffset);
     return r;
      r.setEnd(endNode, endOffset);
      return r;
     } catch(e){ return null; }
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // REMOVED: injectAnnotationCss(), which JS-injected a defensive
   // BOOT
   // .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 () {
   $(function() {
    _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    window.addEventListener('resize', function(){
      _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    });
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();
     loadBookmarks();
     hydrateCitationSources();
     restoreBookmarkHighlights();
     // Commentary blocks arrive after the page does, so hydrate again when
     restoreCommentHighlights();
    // they land rather than only once at boot.
    loadComments(function(){}); // pre-load comments in background
    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() { }


}() );
}() );