MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback  (v6 + Strategy B + Footnotes tab)
  * (v7 — server-backed storage + unified highlight→card behaviour)
*
* CHANGED IN v7:
*  1. Clicking a bookmark or footnote highlight now behaves exactly like
*      clicking a note highlight (panel opens, card scrolls into view AND
*      flashes). All three go through one shared focusPanelCard helper.
*  2. Notes and bookmarks are no longer localStorage-only. They're stored
*      server-side via the GranthaAnnotations extension's API, so the same
*      data is readable from the React Native app (and from any other
*      client) with the user's existing MediaWiki session. localStorage
*      is still used, but only as an offline cache + a write queue.
*
* Storage model, in short:
*  - Signed out  → localStorage only, exactly as before. Nothing breaks
*                  for anonymous readers, and nothing of theirs is lost.
*  - Signed in  → the server is the source of truth. Every read is
*                  mirrored into localStorage so the page still renders
*                  annotations while offline; every write is optimistic
*                  (UI updates immediately) and queued for retry if the
*                  request fails.
*  - On first signed-in load of a page, any pre-existing localStorage
*    annotations for that page are pushed up to the server once, then
*    the page is marked migrated so it never double-uploads.
*
* Footnotes are deliberately NOT part of this. They're real saved page
* content written by QuickEdit into the article HTML, not per-user data —
* the Footnotes tab reads them straight off the DOM, same as before.
  */
  */


Line 42: Line 15:
   var currentUserEmail = '';
   var currentUserEmail = '';


  /* ══════════════════════ storage backend ══════════════════════ */
   // FIXED: was `new mw.Api()` called directly and synchronously here —
 
   // at this point in MediaWiki:Common.js's own load lifecycle,
  var API_READ        = 'granthaannotations';
   // mediawiki.api (the module that provides the REAL mw.Api
  var API_WRITE      = 'granthaannotationedit';
  // constructor) is not guaranteed to have finished loading yet, and
  var PENDING_LS_KEY  = 'grantha_annot_pending';          // global, not per-page
   // calling the constructor before it has throws "mw.Api is not a
  var MIGRATED_LS_KEY = 'grantha_annot_migrated_' + pageTitle;
   // constructor" synchronously. Confirmed directly via the real error
 
   // trace, and confirmed this is pre-existing code, not anything
  // Whether to talk to the server at all. Decided once, at load: an
   // introduced by the more recent Ullekha/tab changes. Uncaught here
  // anonymous reader has no account to attach annotations to, so they
   // (the .catch below only ever handled promise rejections, never a
  // stay purely local (and keep working exactly as they always have).
   // synchronous throw from the constructor call itself), this would
  var _remote = !!currentUser;
   // 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
   // FIXED (carried over from v6): mw.Api must never be constructed
   // whatever page load hits this race. mw.loader.using() guarantees the
   // synchronously at file scope — at this point in Common.js's own load
   // module is actually ready before mw.Api is ever touched, regardless
   // lifecycle the mediawiki.api module isn't guaranteed to be ready, and
   // of load timing. Verified the exact failure mode and the fix
   // calling the constructor early throws "mw.Api is not a constructor",
   // directly before applying here.
   // which would halt the rest of this script. Everything that needs the
   // API goes through this one lazy, memoised getter instead.
  var _apiPromise = null;
  function api() {
    if ( !_apiPromise ) {
      _apiPromise = mw.loader.using( 'mediawiki.api' ).then( function () {
        return new mw.Api();
      } );
    }
    return _apiPromise;
  }
 
  function resolved( val ) { return $.Deferred().resolve( val ).promise(); }
 
  function lsGet( key ) {
    try { return JSON.parse( localStorage.getItem( key ) || '[]' ) || []; }
    catch ( e ) { return []; }
  }
  function lsSet( key, val ) {
    try { localStorage.setItem( key, JSON.stringify( val ) ); } catch ( e ) {}
  }
 
  /* ── write queue ──
  * A failed write is never silently dropped. It goes here, and the next
  * page load flushes it before reading. The queue is global rather than
  * per-page because a write can fail on one page and only get retried
  * once the user has navigated to another — so each queued op carries
  * its own page rather than assuming the current one.
  */
  function pendingAll() { return lsGet( PENDING_LS_KEY ); }
  function pendingSet( q ) { lsSet( PENDING_LS_KEY, q ); }
  function pendingPush( op ) { var q = pendingAll(); q.push( op ); pendingSet( q ); }
 
  function rawWrite( op ) {
    return api().then( function ( a ) {
      var params = {
        action: API_WRITE,
        op: op.op,
        id: op.record.id,
        format: 'json',
        formatversion: 2
      };
      if ( op.op === 'save' ) {
        params.page  = op.page;
        params.type  = op.type;
        params.quote = op.record.quote || '';
        params.text  = op.record.text || '';
        params.ts    = op.record.ts || '';
      }
      return a.postWithToken( 'csrf', params );
    } );
  }
 
   // Flushes the queue one op at a time, in order. Order matters: a
  // save→delete pair for the same id replayed out of order would
  // resurrect a deleted annotation. Anything that fails again is put
   // back for the next attempt rather than discarded.
  function flushPending() {
    var queue = pendingAll();
    if ( !queue.length ) return resolved();
    pendingSet( [] );
    var failed = [];
    return queue.reduce( function ( chain, op ) {
      return chain.then( function () {
        return rawWrite( op ).then( null, function () { failed.push( op ); } );
      } );
    }, resolved() ).then( function () {
      if ( failed.length ) pendingSet( pendingAll().concat( failed ) );
      return failed.length;
    } );
  }
 
  // One-time upload of whatever this browser already had stored locally
  // for this page, so a user who has been annotating anonymously (or
   // before this change shipped) doesn't lose anything on first sign-in.
  // Only marks the page migrated once the queue has actually drained —
  // a failed upload must be retried, not forgotten.
  function migrateLocalIfNeeded() {
    try { if ( localStorage.getItem( MIGRATED_LS_KEY ) ) return resolved(); } catch ( e ) {}
    var localNotes = lsGet( NT_LS_KEY );
    var localBms  = lsGet( BM_LS_KEY );
    if ( !localNotes.length && !localBms.length ) { markMigrated(); return resolved(); }
    localNotes.forEach( function ( n ) {
      pendingPush( { op: 'save', type: 'note', page: pageTitle,
        record: { id: n.id, quote: n.quote, text: n.text, ts: n.ts } } );
    } );
    localBms.forEach( function ( b ) {
      // Bookmarks call their body "name" in the UI; the server stores one
      // "text" column for both types.
      pendingPush( { op: 'save', type: 'bookmark', page: pageTitle,
        record: { id: b.id, quote: b.quote, text: b.name, ts: b.ts } } );
    } );
    return flushPending().then( function ( failedCount ) {
      if ( !failedCount ) markMigrated();
    } );
  }
  function markMigrated() {
    try { localStorage.setItem( MIGRATED_LS_KEY, '1' ); } catch ( e ) {}
  }
 
  function toNote( row ) {
    return { id: row.id, ts: row.ts, quote: row.quote || '', text: row.text || '' };
  }
  function toBookmark( row ) {
    return { id: row.id, ts: row.ts, quote: row.quote || '', name: row.text || '' };
  }
 
   // Loads this page's annotations into _notes / _bookmarks. Always
  // resolves — a server that's down degrades to the local cache rather
   // than leaving the panel empty and the page un-highlighted.
   function loadPageAnnotations() {
    if ( !_remote ) {
      _notes    = lsGet( NT_LS_KEY );
      _bookmarks = lsGet( BM_LS_KEY );
      return resolved();
    }
    return flushPending()
      .then( migrateLocalIfNeeded )
      .then( api )
      .then( function ( a ) {
        return a.get( {
          action: API_READ, page: pageTitle, limit: 500,
          format: 'json', formatversion: 2
        } );
      } )
      .then( function ( data ) {
        var rows = ( data && data[ API_READ ] && data[ API_READ ].annotations ) || [];
        _notes    = rows.filter( function ( r ) { return r.type === 'note'; } ).map( toNote );
        _bookmarks = rows.filter( function ( r ) { return r.type === 'bookmark'; } ).map( toBookmark );
        // Mirror into localStorage so this page still renders its
        // annotations on a later offline visit.
        lsSet( NT_LS_KEY, _notes );
        lsSet( BM_LS_KEY, _bookmarks );
      }, function ( code, result ) {
        console.error( 'grantha annotations: load failed, using local cache:', code, result );
        _notes    = lsGet( NT_LS_KEY );
        _bookmarks = lsGet( BM_LS_KEY );
        showSyncNotice( 'Offline — showing your saved copy. Changes will sync later.' );
      } );
  }
 
  // Both writers below are optimistic: _notes/_bookmarks and the local
   // mirror are already updated by the caller, so the UI never waits on
   // the network. The server call is fire-and-forget with a retry queue.
   function storeSave( type, record ) {
    lsSet( type === 'note' ? NT_LS_KEY : BM_LS_KEY,
          type === 'note' ? _notes : _bookmarks );
    if ( !_remote ) return;
    var op = { op: 'save', type: type, page: pageTitle, record: record };
    rawWrite( op ).then( null, function ( code, result ) {
      console.error( 'grantha annotations: save failed, queued for retry:', code, result );
      pendingPush( op );
      showSyncNotice( 'Saved on this device. Will sync when you\u2019re back online.' );
    } );
  }
  function storeDelete( type, id ) {
    lsSet( type === 'note' ? NT_LS_KEY : BM_LS_KEY,
          type === 'note' ? _notes : _bookmarks );
    if ( !_remote ) return;
    var op = { op: 'delete', type: type, page: pageTitle, record: { id: id } };
    rawWrite( op ).then( null, function ( code, result ) {
      console.error( 'grantha annotations: delete failed, queued for retry:', code, result );
      pendingPush( op );
    } );
  }
 
  var _syncNoticeTimer = null;
  function showSyncNotice( msg ) {
    var $n = $( '#gra-sync-notice' );
    if ( !$n.length ) {
      $n = $( '<div id="gra-sync-notice" role="status" aria-live="polite"></div>' );
      $( 'body' ).append( $n );
    }
    $n.text( msg ).addClass( 'gra-sync-notice-visible' );
    clearTimeout( _syncNoticeTimer );
    _syncNoticeTimer = setTimeout( function () {
      $n.removeClass( 'gra-sync-notice-visible' );
    }, 4000 );
  }
 
  /* ══════════════════════ user email (for the feedback form) ══════════════════════ */
 
   if ( currentUser && window.mw ) {
   if ( currentUser && window.mw ) {
     api().then( function ( a ) {
     mw.loader.using( 'mediawiki.api' ).then( function () {
       return a.get( { action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 } );
       return new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 });
     } ).then( function ( data ) {
     } ).then( function (data) {
       var info = data && data.query && data.query.userinfo;
       var info = data && data.query && data.query.userinfo;
       if ( info && info.email ) currentUserEmail = info.email;
       if ( info && info.email ) currentUserEmail = info.email;
Line 285: Line 76:
   var $bmComposer, $bmInput, $bmSubmit;
   var $bmComposer, $bmInput, $bmSubmit;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
  // CHANGED: added $tabFootnotes / $paneFootnotes alongside the existing
  // Notes/Bookmarks tab+pane refs, for the Footnotes tab. REMOVED: the
  // earlier $tabSandarbha/$paneSandarbha — that feature was replaced by
  // the real Ullekha citation-index mechanism (see quickedit.js), which
  // works entirely through common.js's own site-wide logic and needs no
  // panel integration here at all.
   var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes;
   var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes;


Line 610: Line 407:
     var span  = wrapSelection(id, 'gra-note-highlight');
     var span  = wrapSelection(id, 'gra-note-highlight');
     if (span) span.setAttribute('data-gra-quote', quote);
     if (span) span.setAttribute('data-gra-quote', quote);
     var note = {id:id, ts:ts, quote:quote, text:text};
     _notes.push({id:id, ts:ts, quote:quote, text:text});
     _notes.push(note);
     persistNotes();
     // CHANGED: was persistNotes() + persistNoteHighlight(). The separate
     persistNoteHighlight(id, quote);
    // '_hl' localStorage list is gone entirely — it only ever held
    // {id, quote}, which the note record itself already carries, so
    // restoreNoteHighlights now reads straight from _notes. One less
    // thing to keep in sync, and one less thing that could drift out of
    // sync with the server copy.
    storeSave('note', note);
     renderNoteCards();
     renderNoteCards();
     closeNoteComposer();
     closeNoteComposer();
Line 624: Line 415:
   }
   }


   function deleteNote(id) {
   function persistNotes() {
     _notes = _notes.filter(function(n){ return n.id !== id; });
     try { localStorage.setItem(NT_LS_KEY, JSON.stringify(_notes)); } catch(e){}
    var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
  }
     if (span && span.parentNode) {
  function loadNotes() {
      var p = span.parentNode;
     try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
      while (span.firstChild) p.insertBefore(span.firstChild, span);
      p.removeChild(span);
    }
    storeDelete('note', id);
    renderNoteCards();
   }
   }


Line 658: Line 444:
     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); }
     var bm = {id:id, name:name, quote:quote, ts:nowIso()};
     _bookmarks.push({id:id, name:name, quote:quote, ts:nowIso()});
    _bookmarks.push(bm);
     persistBookmarks();
     // The server column is "text" for both types — see toBookmark().
    storeSave('bookmark', {id:bm.id, quote:bm.quote, text:bm.name, ts:bm.ts});
     renderBookmarkCards();
     renderBookmarkCards();
     closeBookmarkComposer();
     closeBookmarkComposer();
Line 675: Line 459:
       p.removeChild(span);
       p.removeChild(span);
     }
     }
     storeDelete('bookmark', id);
     persistBookmarks(); renderBookmarkCards();
     renderBookmarkCards();
  }
 
  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){}
   }
   }


Line 689: Line 479:
     $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;
Line 722: Line 513:
     });
     });
     $paneNotes.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);
    }
    try {
      var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
      s = s.filter(function(h){ return h.id !== id; });
      localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
    } catch(e){}
    persistNotes(); renderNoteCards();
   }
   }


Line 743: Line 550:
   }
   }


   // Footnotes aren't personal annotations — they're real saved page
   // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal
   // content (QuickEdit writes .gra-qe-footnotes/.gra-qe-footnote-item
  // localStorage annotations, they're real saved page content (QuickEdit
   // into the article HTML). So this reads straight off the live DOM on
   // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved
   // every open: no fetch, no storage, and deliberately not part of the
   // HTML). So this reads straight off the live DOM on every open, no
   // per-user sync above.
   // fetch, no storage — verified the scan + both-direction lookup logic
   // against realistic saved-footnote HTML before wiring this in (6/6 pass).
   function renderFootnoteCards() {
   function renderFootnoteCards() {
     var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item');
     var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item');
Line 760: Line 568:
       var textEl = li.querySelector('.gra-qe-footnote-text');
       var textEl = li.querySelector('.gra-qe-footnote-text');
       var num = numEl ? numEl.textContent.trim() : '';
       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 quote = quoteEl ? quoteEl.textContent : '';
       var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent);
       var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent);
Line 781: Line 599:
     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);
  }
  // NEW: shared "open the panel, scroll to the matching card, flash it"
  // step for all three highlight types. This was written out inline three
  // times, and the bookmark copy was only half-written — it scrolled the
  // card into view but never added gra-card-active, so clicking a
  // bookmark highlight behaved subtly differently from a note one for no
  // deliberate reason.
  //
  // The 100ms wait is load-bearing, not arbitrary: openPanel → switchTab
  // re-renders the pane's HTML from scratch, so the card element this
  // looks up does not exist until after that has run.
  function focusPanelCard($pane, tab, id) {
    if (!id) return;
    openPanel(tab);
    setTimeout(function () {
      var $card = $pane.find('[data-gra-id="' + id + '"]');
      if (!$card.length) return;
      $card.addClass('gra-card-active');
      $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
      setTimeout(function () { $card.removeClass('gra-card-active'); }, 2000);
    }, 100);
   }
   }


Line 974: Line 770:
     $tabNotes.on('click', function(){ switchTab('notes'); });
     $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'); });
     $tabFootnotes.on('click', function(){ switchTab('footnotes'); });


    /* Panel card → jump to the highlight in the text */
     $paneNotes.on('click', '.gra-note-card', function(e){
     $paneNotes.on('click', '.gra-note-card', function(e){
       if ($(e.target).hasClass('gra-note-del')) return;
       if ($(e.target).hasClass('gra-note-del')) return;
Line 997: Line 793:
       if (id) deleteBookmark(id);
       if (id) deleteBookmark(id);
     });
     });
     // Footnote highlights carry data-gra-id (see quickedit.js's redesign —
     // CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's
     // plain highlight span, no href/marker element), so this reuses the
     // redesign — plain highlight span, no href/marker element anymore), so
     // same scrollToHighlight helper as notes and bookmarks.
     // 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(){
     $paneFootnotes.on('click', '.gra-footnote-card', function(){
       var id = $(this).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
Line 1,005: Line 803:
     });
     });


    /* Highlight in the text → open the panel on its card.
      All three now go through focusPanelCard, so they behave identically. */
     $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
     $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
       focusPanelCard($paneNotes, 'notes', $(this).attr('data-gra-id'));
       var id = $(this).attr('data-gra-id');
      openPanel('notes');
      setTimeout(function(){
        var $card = $paneNotes.find('[data-gra-id="'+id+'"]');
        if ($card.length) {
          $card.addClass('gra-card-active');
          $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
        }
      }, 100);
     });
     });
     $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
     $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
       focusPanelCard($paneBookmarks, 'bookmarks', $(this).attr('data-gra-id'));
       var id = $(this).attr('data-gra-id');
      openPanel('bookmarks');
      setTimeout(function(){
        var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
        if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
      }, 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(){
     $(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){
       focusPanelCard($paneFootnotes, 'footnotes', $(this).attr('data-gra-id'));
       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);
     });
     });


     // Cross-document links from QuickEdit's Link picker. No real href is
     // NEW: cross-document links from QuickEdit's Link picker. No real
     // ever saved (MediaWiki's sanitizer escapes <a href> into visible
     // href is ever saved (confirmed directly: MediaWiki's sanitizer
    // literal text on this wiki, even for a full absolute URL) — the
    // escapes <a href> into visible literal text on this wiki, even for
    // target lives in data-gr-href, and this handler navigates.
    // 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(){
     $(CONTENT_SEL).on('click', '.gr-crosslink', function(){
       var href = $(this).attr('data-gr-href');
       var href = $(this).attr('data-gr-href');
       if (!href) return;
       if (!href) return;
       var newWin = window.open(href, '_blank');
       var newWin = window.open(href, '_blank');
       if (newWin) newWin.opener = null; // more reliable cross-browser than window.open's features string
       if (newWin) newWin.opener = null; // more reliable cross-browser than relying on window.open's features string
     });
     });


Line 1,037: Line 861:
   }
   }


   // CHANGED: re-anchors note highlights from _notes directly, rather than
   function persistNoteHighlight(id, quote) {
  // from the old separate NT_LS_KEY+'_hl' list. Each note already carries
    try {
  // its own quote, so that parallel list was duplicated state — and once
      var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
  // notes come from the server, a stale local '_hl' copy would have been
      s = s.filter(function(h){ return h.id !== id; });
   // an active source of wrong highlights.
      s.push({id:id, quote:quote});
      localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
    } catch(e){}
   }
 
   function restoreNoteHighlights() {
   function restoreNoteHighlights() {
     _notes.forEach(function(n){
     var s = [];
       if (!n.quote || !n.id) return;
    try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){}
       if (document.querySelector('[data-gra-id="'+n.id+'"].gra-note-highlight')) return;
    s.forEach(function(h){
       var needle = n.quote.replace(/…$/,'').trim().slice(0,80);
       if (!h.quote || !h.id) return;
       if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return;
       var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
       if (!needle) return;
       if (!needle) return;
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
Line 1,052: Line 882:
       var sp = document.createElement('span');
       var sp = document.createElement('span');
       sp.className = 'gra-note-highlight';
       sp.className = 'gra-note-highlight';
       sp.setAttribute('data-gra-id', n.id);
       sp.setAttribute('data-gra-id', h.id);
      sp.setAttribute('data-gra-quote', n.quote);
       try { range.surroundContents(sp); } catch(e){}
       try { range.surroundContents(sp); } catch(e){}
     });
     });
Line 1,095: Line 924:
       return r;
       return r;
     } catch(e){ return null; }
     } catch(e){ return null; }
  }
  // NEW: defensive fix for a reported gap — clicking a footnote (or
  // bookmark) card in the panel correctly scrolled to the right spot
  // (confirmed directly: both the inline marker and the list item share
  // the exact same data-gra-id, and scrollToHighlight's own logic is
  // fully generic, not specific to notes at all), but the visual
  // "flash" highlight wasn't visible. Best-reasoned explanation, not
  // confirmed against the actual site stylesheet (not available here):
  // .gra-hl-active likely has CSS specific to .gra-note-highlight /
  // .gra-bookmark-highlight but no matching rule for
  // .gra-qe-footnote-highlight. This rule is unconditional — the flash
  // renders the same way regardless of which of the three classes it's
  // combined with — so it's safe even if the actual cause turns out to
  // be something else.
  function injectAnnotationCss() {
    if (document.getElementById('gra-hl-active-css')) return;
    var css = [
      '.gra-hl-active{background:#fff176!important;border-radius:2px;box-shadow:0 0 0 2px #ffb300!important;transition:background 0.3s,box-shadow 0.3s;}',
    ].join('\n');
    var style = document.createElement('style');
    style.id = 'gra-hl-active-css';
    style.textContent = css;
    document.head.appendChild(style);
   }
   }


Line 1,102: Line 955:
       _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
       _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
     });
     });
    injectAnnotationCss();
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();
 
     loadNotes();
     // CHANGED: loading is now asynchronous (it may hit the network), so
     loadBookmarks();
    // rendering and re-highlighting happen in the callback rather than
    setTimeout(function(){
    // immediately. Runs on both success and failure — loadPageAnnotations
      try { restoreNoteHighlights(); } catch(e){}
     // falls back to the local cache internally rather than rejecting, so
      try { restoreBookmarkHighlights(); } catch(e){}
    // the page is never left un-highlighted just because the wiki is slow.
     }, 500);
    function afterLoad() {
      renderNoteCards();
      renderBookmarkCards();
      setTimeout(function(){
        try { restoreNoteHighlights(); } catch(e){}
        try { restoreBookmarkHighlights(); } catch(e){}
      }, 300);
     }
    loadPageAnnotations().then(afterLoad, afterLoad);
   });
   });


}() );
}() );