Jump to content

MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

From Anandamakaranda
No edit summary
No edit summary
 
(13 intermediate revisions by the same user not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v6 + Strategy B)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback
  * (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 14: Line 41:
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   var currentUserEmail = '';
   var currentUserEmail = '';
  /* ══════════════════════ storage backend ══════════════════════ */
  var API_READ        = 'granthaannotations';
  var API_WRITE      = 'granthaannotationedit';
  var PENDING_LS_KEY  = 'grantha_annot_pending';          // global, not per-page
  var MIGRATED_LS_KEY = 'grantha_annot_migrated_' + pageTitle;
  // Whether to talk to the server at all. Decided once, at load: an
  // anonymous reader has no account to attach annotations to, so they
  // stay purely local (and keep working exactly as they always have).
  var _remote = !!currentUser;
  // FIXED (carried over from v6): mw.Api must never be constructed
  // synchronously at file scope — at this point in Common.js's own load
  // lifecycle the mediawiki.api module isn't guaranteed to be ready, and
  // calling the constructor early throws "mw.Api is not a constructor",
  // 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 ) {
     new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 })
     api().then( function ( a ) {
      .then( function (data) {
      return a.get( { action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 } );
        var info = data && data.query && data.query.userinfo;
    } ).then( function ( data ) {
        if ( info && info.email ) currentUserEmail = info.email;
      var info = data && data.query && data.query.userinfo;
      } ).catch( function () {} );
      if ( info && info.email ) currentUserEmail = info.email;
    } ).catch( function () {} );
   }
   }


Line 59: Line 285:
   var $bmComposer, $bmInput, $bmSubmit;
   var $bmComposer, $bmInput, $bmSubmit;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;
   var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes;


   function buildDom() {
   function buildDom() {
Line 79: Line 305:
       '    <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
       '    <span class="gra-fab-btn-label">Search</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>',
Line 159: Line 389:
       '    <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>',
Line 164: Line 397:
       '    <div class="gra-pane gra-pane-active" id="gra-pane-notes"></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>',
Line 186: Line 420:
     $tabNotes    = $('#gra-tab-notes');
     $tabNotes    = $('#gra-tab-notes');
     $tabBookmarks = $('#gra-tab-bookmarks');
     $tabBookmarks = $('#gra-tab-bookmarks');
    $tabFootnotes = $('#gra-tab-footnotes');
     $paneNotes    = $('#gra-pane-notes');
     $paneNotes    = $('#gra-pane-notes');
     $paneBookmarks= $('#gra-pane-bookmarks');
     $paneBookmarks= $('#gra-pane-bookmarks');
    $paneFootnotes= $('#gra-pane-footnotes');
     $ntInput      = $('#gra-nt-input');
     $ntInput      = $('#gra-nt-input');
     $ntSubmit    = $('#gra-nt-submit');
     $ntSubmit    = $('#gra-nt-submit');
Line 213: Line 449:
                     document.querySelector('.se-outer');
                     document.querySelector('.se-outer');
     if ( _editorEl && _editorEl.contains(ancestor) ) return false;
     if ( _editorEl && _editorEl.contains(ancestor) ) return false;
    if ( document.body.classList.contains('gra-editing') ) return false;
     _selText  = text;
     _selText  = text;
     _selRect  = range.getBoundingClientRect();
     _selRect  = range.getBoundingClientRect();
Line 243: Line 480:
     var fabW, fabH, top, left;
     var fabW, fabH, top, left;
     if (_mobile) {
     if (_mobile) {
      fabW = 200; fabH = 48;
       $fab.css({ position: '', top: '', left: '', visibility: '' })
      /* Strategy B: place fab BELOW the selection — the native iOS/Android
           .addClass('gra-fab-visible gra-fab-mobile-docked');
        selection menu appears ABOVE it, so the two never collide. */
      top  = rect.bottom + window.scrollY + 14;
      left = rect.left + rect.width / 2 - fabW / 2;
      /* Near viewport bottom: flip above, clearing the native bar (~42px) */
      if (rect.bottom + fabH + 22 > window.innerHeight) {
        top = rect.top + window.scrollY - fabH - 56;
      }
      top  = clamp(top,  window.scrollY + 8, window.scrollY + window.innerHeight - fabH - 8);
      left = clamp(left, 8, window.innerWidth - fabW - 8);
       $fab.css({ position: 'absolute', top: top + 'px', left: '20%' })
           .addClass('gra-fab-visible');
       return;
       return;
     }
     }
Line 267: Line 493:
   }
   }


   function hideFab() { $fab.removeClass('gra-fab-visible'); }
   function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
   function hideActions() { hideFab(); }
   function hideActions() { hideFab(); }


Line 384: Line 610:
     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);
     _notes.push({id:id, ts:ts, quote:quote, text:text});
     var note = {id:id, ts:ts, quote:quote, text:text};
     persistNotes();
     _notes.push(note);
     persistNoteHighlight(id, quote);
     // CHANGED: was persistNotes() + persistNoteHighlight(). The separate
    // '_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 392: Line 624:
   }
   }


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


Line 421: Line 658:
     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()});
     var bm = {id:id, name:name, quote:quote, ts:nowIso()};
     persistBookmarks();
    _bookmarks.push(bm);
     // 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 436: Line 675:
       p.removeChild(span);
       p.removeChild(span);
     }
     }
     persistBookmarks(); renderBookmarkCards();
     storeDelete('bookmark', id);
  }
     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 460: Line 693:
     $tabNotes.toggleClass('gra-tab-active', tab==='notes');
     $tabNotes.toggleClass('gra-tab-active', tab==='notes');
     $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
     $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
    $tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes');
     $paneNotes.toggleClass('gra-pane-active', tab==='notes');
     $paneNotes.toggleClass('gra-pane-active', tab==='notes');
     $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
     $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
    $paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes');
     if (tab==='notes') renderNoteCards();
     if (tab==='notes') renderNoteCards();
     else renderBookmarkCards();
     else if (tab==='bookmarks') renderBookmarkCards();
    else renderFootnoteCards();
   }
   }


Line 475: Line 711:
       html += '<div class="gra-note-card" data-gra-id="'+esc(n.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"></div>'
             + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>'
             + '<div class="gra-card-meta">'
             + '<div class="gra-card-meta">'
             + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
             + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
Line 486: Line 722:
     });
     });
     $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 521: Line 741:
     });
     });
     $paneBookmarks.html(html);
     $paneBookmarks.html(html);
  }
  // Footnotes aren't personal annotations — they're real saved page
  // content (QuickEdit writes .gra-qe-footnotes/.gra-qe-footnote-item
  // into the article HTML). So this reads straight off the live DOM on
  // every open: no fetch, no storage, and deliberately not part of the
  // per-user sync above.
  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;
    }
    var html = '';
    Array.prototype.forEach.call(items, function (li) {
      var numEl = li.querySelector('.gra-qe-footnote-num');
      var quoteEl = li.querySelector('.gra-qe-footnote-quote');
      var textEl = li.querySelector('.gra-qe-footnote-text');
      var num = numEl ? numEl.textContent.trim() : '';
      var quote = quoteEl ? quoteEl.textContent : '';
      var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent);
      var primary = quote ? (num + ' ' + quote) : (num + ' ' + text);
      var id = li.getAttribute('data-gra-id') || '';
      html += '<div class="gra-bookmark-card gra-footnote-card" data-gra-id="'+esc(id)+'">'
            + '<span class="gra-icon gra-icon-footnote" aria-hidden="true"></span>'
            + '<div class="gra-bookmark-info">'
            + '<div class="gra-bookmark-name">'+esc(primary)+'</div>'
            + (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '')
            + '</div>'
            + '</div>';
    });
    $paneFootnotes.html(html);
   }
   }


Line 529: Line 781:
     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 548: Line 822:
     });
     });


     /* Mobile: show fab once selection settles (~350ms, alongside native menu).
    /* Separate timers so mobile + desktop never clobber each other */
       Strategy B: don't race the native menu — appear with it, in our own space. */
    var _selTimer    = null;  /* desktop debounce */
    var _lastTouchEnd = 0;
    var _mobShowTimer = null; /* mobile show-on-touchend */
 
     /* Mobile: show fab quickly after finger lifts (selection settled).
       180ms feels instant while still letting the range stabilise. */
     document.addEventListener('touchend', function(e) {
     document.addEventListener('touchend', function(e) {
       if (!_mobile) return;
       if (!_mobile) return;
       if ($fab[0] && $fab[0].contains(e.target)) return;
       if ($fab[0] && $fab[0].contains(e.target)) return;
      _lastTouchEnd = Date.now();
       clearTimeout(_mobShowTimer);
       clearTimeout(_selTimer);
       _mobShowTimer = setTimeout(function() {
       _selTimer = setTimeout(function() {
         var sel = window.getSelection();
         var sel = window.getSelection();
         if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
         if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
         tryShowActions();
         tryShowActions();
       }, 350);
       }, 180);
     }, { passive: true });
     }, { passive: true });


     /* Mobile: reposition fab live while user drags the selection handles,
     /* Mobile: only HIDE the fab when selection is cleared while it's visible.
       hide it if selection is cleared */
      (Reposition isn't needed now that the bar is docked, and re-running
       showFab here was causing the lag/flicker.) */
     document.addEventListener('selectionchange', function() {
     document.addEventListener('selectionchange', function() {
       if (!_mobile) return;
       if (!_mobile) return;
       if (!$fab.hasClass('gra-fab-visible')) return;
       if (!$fab.hasClass('gra-fab-visible')) return;
       clearTimeout(_selTimer);
       var sel = window.getSelection();
      _selTimer = setTimeout(function() {
      if (!sel || sel.isCollapsed || !sel.toString().trim()) {
        var sel = window.getSelection();
        clearTimeout(_mobShowTimer);
        if (!sel || sel.isCollapsed || !sel.toString().trim()) { hideActions(); return; }
         hideActions();
         if (captureSelection()) showFab(_selRect);
       }
       }, 250);
     });
     });


     /* selectionchange debounced (desktop) */
     /* selectionchange debounced (desktop only) */
    var _selTimer = null;
     document.addEventListener('selectionchange', function() {
     document.addEventListener('selectionchange', function() {
      if (_mobile) return;
       _selVersion++;
       _selVersion++;
       clearTimeout(_selTimer);
       clearTimeout(_selTimer);
Line 585: Line 861:
         if (v !== _selVersion) return;
         if (v !== _selVersion) return;
         if (_fabSelVer === v) return;
         if (_fabSelVer === v) return;
        if (_mobile) return; /* mobile uses touchend instead */
         tryShowActions();
         tryShowActions();
       }, 600);
       }, 600);
Line 647: Line 922:
       else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
       else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
     });
     });
    /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
    (function () {
      var dismissEl = document.getElementById('gra-fab-dismiss');
      if (!dismissEl) return;
      function doDismiss(e) {
        e.preventDefault(); e.stopPropagation();
        hideActions();
        _selRange = null; _selText = ''; _selRect = null;
        if (window.getSelection) {
          var s = window.getSelection();
          if (s && s.removeAllRanges) s.removeAllRanges();
        }
      }
      dismissEl.addEventListener('touchend', doDismiss, { passive: false });
      dismissEl.addEventListener('click', doDismiss);
    }());


     /* Feedback composer */
     /* Feedback composer */
Line 682: Line 974:
     $tabNotes.on('click', function(){ switchTab('notes'); });
     $tabNotes.on('click', function(){ switchTab('notes'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
    $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 702: Line 996:
       var id = $(this).attr('data-del-id');
       var id = $(this).attr('data-del-id');
       if (id) deleteBookmark(id);
       if (id) deleteBookmark(id);
    });
    // Footnote highlights carry data-gra-id (see quickedit.js's redesign —
    // plain highlight span, no href/marker element), so this reuses the
    // same scrollToHighlight helper as notes and bookmarks.
    $paneFootnotes.on('click', '.gra-footnote-card', function(){
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
     });
     });


    /* 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(){
       var id = $(this).attr('data-gra-id');
       focusPanelCard($paneNotes, 'notes', $(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(){
       var id = $(this).attr('data-gra-id');
       focusPanelCard($paneBookmarks, 'bookmarks', $(this).attr('data-gra-id'));
      openPanel('bookmarks');
    });
      setTimeout(function(){
    $(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){
        var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
      focusPanelCard($paneFootnotes, 'footnotes', $(this).attr('data-gra-id'));
        if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
    });
       }, 100);
 
    // Cross-document links from QuickEdit's Link picker. No real href is
    // ever saved (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, and this handler navigates.
    $(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 window.open's features string
     });
     });


Line 734: Line 1,037:
   }
   }


   function persistNoteHighlight(id, quote) {
   // CHANGED: re-anchors note highlights from _notes directly, rather than
    try {
  // from the old separate NT_LS_KEY+'_hl' list. Each note already carries
      var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
  // its own quote, so that parallel list was duplicated state — and once
      s = s.filter(function(h){ return h.id !== id; });
  // notes come from the server, a stale local '_hl' copy would have been
      s.push({id:id, quote:quote});
   // an active source of wrong highlights.
      localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
    } catch(e){}
   }
 
   function restoreNoteHighlights() {
   function restoreNoteHighlights() {
     var s = [];
     _notes.forEach(function(n){
    try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){}
       if (!n.quote || !n.id) return;
    s.forEach(function(h){
       if (document.querySelector('[data-gra-id="'+n.id+'"].gra-note-highlight')) return;
       if (!h.quote || !h.id) return;
       var needle = n.quote.replace(/…$/,'').trim().slice(0,80);
       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 755: Line 1,052:
       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', h.id);
       sp.setAttribute('data-gra-id', n.id);
      sp.setAttribute('data-gra-quote', n.quote);
       try { range.surroundContents(sp); } catch(e){}
       try { range.surroundContents(sp); } catch(e){}
     });
     });
Line 806: Line 1,104:
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();
     loadNotes();
 
    loadBookmarks();
    // CHANGED: loading is now asynchronous (it may hit the network), so
    setTimeout(function(){
     // rendering and re-highlighting happen in the callback rather than
      try { restoreNoteHighlights(); } catch(e){}
    // immediately. Runs on both success and failure — loadPageAnnotations
      try { restoreBookmarkHighlights(); } catch(e){}
    // falls back to the local cache internally rather than rejecting, so
     }, 500);
    // the page is never left un-highlighted just because the wiki is slow.
    function afterLoad() {
      renderNoteCards();
      renderBookmarkCards();
      setTimeout(function(){
        try { restoreNoteHighlights(); } catch(e){}
        try { restoreBookmarkHighlights(); } catch(e){}
      }, 300);
     }
    loadPageAnnotations().then(afterLoad, afterLoad);
   });
   });


}() );
}() );

Latest revision as of 03:00, 2 September 2026

/**
 * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback
 * (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.
 */

/* global mw, $ */
( function () {
  'use strict';

  var CONTENT_SEL   = '#mw-content-text';
  var BM_LS_KEY     = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
  var NT_LS_KEY     = 'grantha_nt_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
  var pageTitle     = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
  var currentUser   = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
  var userInitial   = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
  var currentUserEmail = '';

  /* ══════════════════════ storage backend ══════════════════════ */

  var API_READ        = 'granthaannotations';
  var API_WRITE       = 'granthaannotationedit';
  var PENDING_LS_KEY  = 'grantha_annot_pending';           // global, not per-page
  var MIGRATED_LS_KEY = 'grantha_annot_migrated_' + pageTitle;

  // Whether to talk to the server at all. Decided once, at load: an
  // anonymous reader has no account to attach annotations to, so they
  // stay purely local (and keep working exactly as they always have).
  var _remote = !!currentUser;

  // FIXED (carried over from v6): mw.Api must never be constructed
  // synchronously at file scope — at this point in Common.js's own load
  // lifecycle the mediawiki.api module isn't guaranteed to be ready, and
  // calling the constructor early throws "mw.Api is not a constructor",
  // 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 ) {
    api().then( function ( a ) {
      return a.get( { action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 } );
    } ).then( function ( data ) {
      var info = data && data.query && data.query.userinfo;
      if ( info && info.email ) currentUserEmail = info.email;
    } ).catch( function () {} );
  }

  if ( window.mw ) {
    var ns = mw.config.get( 'wgNamespaceNumber' );
    if ( ns < 0 ) return;
  }

  var _selRange   = null;
  var _selText    = '';
  var _selRect    = null;
  var _notes      = [];
  var _bookmarks  = [];
  var _activeTab  = 'notes';
  var _selVersion = 0;
  var _fabSelVer  = -1;
  var _mobile     = window.innerWidth < 768 || 'ontouchstart' in window;
  var _fabTouched = false;  // flag to prevent hideActions when tapping fab

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

  var $fab, $mobileBar, $panel, $backdrop;
  var $ntComposer, $ntInput, $ntSubmit;
  var $bmComposer, $bmInput, $bmSubmit;
  var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
  var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes;

  function buildDom() {
    $fab = $( [
      '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">',
      '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Note">',
      '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Note</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Mark">',
      '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Mark</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Feedback">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Feedback</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search">',
      '    <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Search</span>',
      '  </button>',
      '  <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">',
      '    <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Close</span>',
      '  </button>',
      '</div>',
    ].join('') );
    $('body').append($fab);

    $mobileBar = $('<div id="gra-mobile-bar"></div>');
    $('body').append($mobileBar);

    $fbComposer = $( [
      '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
      '  <div class="gra-composer-header">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '    <strong>Feedback</strong>',
      '    <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>',
      '  </div>',
      '  <div class="gra-fb-quote-label">Selected text:</div>',
      '  <div class="gra-fb-quote" id="gra-fb-quote"></div>',
      '  <div class="gra-fb-field-label">Issue type</div>',
      '  <select class="gra-fb-select" id="gra-fb-issue">',
      '    <option value="">— Choose —</option>',
      '    <option value="wrong_text">Formatting error</option>',
      '    <option value="reference_issue">Reference issue</option>',
      '    <option value="spelling_mistake">Spelling mistake</option>',
      '    <option value="other">Other</option>',
      '  </select>',
      '  <div class="gra-fb-field-label">Details (optional)</div>',
      '  <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>',
      '  <div class="gra-fb-field-label">Your email (optional)</div>',
      '  <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">',
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-fb-submit" disabled>Send</button>',
      '  </div>',
      '  <div class="gra-fb-status" id="gra-fb-status"></div>',
      '</div>',
    ].join('') );
    $('body').append($fbComposer);

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

    $bmComposer = $( [
      '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
      '  <div class="gra-bm-composer-label">',
      '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
      '    Save bookmark',
      '  </div>',
      '  <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">',
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-bm-submit">Save</button>',
      '  </div>',
      '</div>',
    ].join('') );
    $('body').append($bmComposer);

    $panel = $( [
      '<div id="gra-panel" role="complementary" aria-label="Notes">',
      '  <div id="gra-panel-head">',
      '    <div id="gra-panel-title"></div>',
      '    <button id="gra-panel-close" title="Close">✕</button>',
      '  </div>',
      '  <div id="gra-tabs">',
      '    <button class="gra-tab gra-tab-active" id="gra-tab-notes">',
      '      <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes',
      '    </button>',
      '    <button class="gra-tab" id="gra-tab-bookmarks">',
      '      <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks',
      '    </button>',
      '    <button class="gra-tab" id="gra-tab-footnotes">',
      '      <span class="gra-icon gra-icon-footnote" aria-hidden="true"></span> Footnotes',
      '    </button>',
      '  </div>',
      '  <div id="gra-panel-body">',
      '    <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>',
      '    <div class="gra-pane" id="gra-pane-bookmarks"></div>',
      '    <div class="gra-pane" id="gra-pane-footnotes"></div>',
      '  </div>',
      '</div>',
    ].join('') );
    $('body').append($panel);

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

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

    $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
    $tabNotes     = $('#gra-tab-notes');
    $tabBookmarks = $('#gra-tab-bookmarks');
    $tabFootnotes = $('#gra-tab-footnotes');
    $paneNotes    = $('#gra-pane-notes');
    $paneBookmarks= $('#gra-pane-bookmarks');
    $paneFootnotes= $('#gra-pane-footnotes');
    $ntInput      = $('#gra-nt-input');
    $ntSubmit     = $('#gra-nt-submit');
    $bmInput      = $('#gra-bm-input');
    $bmSubmit     = $('#gra-bm-submit');
    $fbIssueType  = $('#gra-fb-issue');
    $fbText       = $('#gra-fb-text');
    $fbEmail      = $('#gra-fb-email');
    $fbSubmit     = $('#gra-fb-submit');
    $fbQuote      = $('#gra-fb-quote');
  }

  function captureSelection() {
    var sel = window.getSelection();
    if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
    var range = sel.getRangeAt(0);
    var text  = sel.toString().trim();
    if (!text || text.length < 2) return false;
    var contentEl = document.querySelector(CONTENT_SEL);
    if (!contentEl) return false;
    var ancestor = range.commonAncestorContainer;
    if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
    if (!ancestor || !contentEl.contains(ancestor)) return false;
    var _editorEl = document.getElementById('se-surface') ||
                    document.querySelector('.se-outer');
    if ( _editorEl && _editorEl.contains(ancestor) ) return false;
    if ( document.body.classList.contains('gra-editing') ) return false;
    _selText  = text;
    _selRect  = range.getBoundingClientRect();
    try { _selRange = range.cloneRange(); }
    catch(e){ _selRange = null; }
    return true;
  }

  function reCaptureFromDOM() {
    if (!_selText) return false;
    var contentEl = document.querySelector(CONTENT_SEL);
    if (!contentEl) return false;
    var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim());
    if (!found) return false;
    _selRange = found;
    return true;
  }

  function tryShowActions() {
    if ($fbComposer && $fbComposer.hasClass('gra-composer-visible')) return;
    if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return;
    if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return;
    if (!captureSelection()) { hideActions(); return; }
    _fabSelVer = _selVersion;
    showFab(_selRect);
  }

  function showFab(rect) {
    if (!rect) return;
    var fabW, fabH, top, left;
    if (_mobile) {
      $fab.css({ position: '', top: '', left: '', visibility: '' })
          .addClass('gra-fab-visible gra-fab-mobile-docked');
      return;
    }
    fabW = 46; fabH = 126;
    top  = rect.top + (rect.height / 2) - (fabH / 2);
    left = rect.right + 10;
    if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10;
    top  = clamp(top,  8, window.innerHeight - fabH - 8);
    left = clamp(left, 8, window.innerWidth  - fabW - 8);
    $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
  }

  function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
  function hideActions() { hideFab(); }

  function wrapSelection(id, cssClass) {
    var range = _selRange;
    _selRange = null;
    if (!range) return null;
    try {
      if (!document.contains(range.startContainer) ||
          !document.contains(range.endContainer)) return null;
    } catch(e) { return null; }
    function makeSpan() {
      var sp = document.createElement('span');
      sp.className = cssClass;
      sp.setAttribute('data-gra-id', id);
      return sp;
    }
    try {
      var span = makeSpan();
      range.surroundContents(span);
      if (span.parentNode) return span;
    } catch(e) {}
    try {
      var frag = range.extractContents();
      var sp2  = makeSpan();
      sp2.appendChild(frag);
      range.insertNode(sp2);
      if (sp2 && sp2.parentNode) return sp2;
    } catch(e2) {}
    return null;
  }

  function openFeedbackComposer() {
    hideActions();
    $fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : ''));
    $fbIssueType.val('');
    $fbText.val('');
    $fbSubmit.prop('disabled', true);
    $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
    if (currentUserEmail) $fbEmail.val(currentUserEmail);
    else $fbEmail.val('');
    if (!_mobile) $fbComposer.css({top:'', left:'', transform:''});
    $fbComposer.addClass('gra-composer-visible');
    $backdrop.addClass('gra-backdrop-visible');
    setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0);
  }

  function closeFeedbackComposer() {
    $fbComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
    _selRange = null; _selText = ''; _selRect = null;
  }

  function submitFeedback() {
    var issueType = $fbIssueType.val();
    var details   = $fbText.val().trim();
    var email     = $fbEmail.val().trim();
    var quote     = $fbQuote.text();
    if (!issueType) return;
    $fbSubmit.prop('disabled', true).text('Sending…');
    $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
    var issueLabels = {
      wrong_text: 'Formatting error', reference_issue: 'Reference issue',
      spelling_mistake: 'Spelling mistake', other: 'Other'
    };
    var payload = new FormData();
    payload.append('issue_type',    issueLabels[issueType] || issueType);
    payload.append('page',          pageTitle.replace(/_/g,' '));
    payload.append('url',           window.location.href);
    payload.append('selected_text', quote);
    payload.append('details',       details || '');
    payload.append('user_email',    email || currentUserEmail || '');
    payload.append('wiki_user',     currentUser || 'anonymous');
    fetch('/feedback.php', {method:'POST', body:payload})
      .then(function(r){ return r.json(); })
      .then(function(data){
        if (data && data.ok) showFeedbackSuccess();
        else showFeedbackError(data && data.error ? data.error : 'Could not send.');
      })
      .catch(function(){ showFeedbackError('Network error. Please try again.'); });
  }

  function showFeedbackSuccess() {
    $fbSubmit.prop('disabled', false).text('Send');
    $('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok');
    setTimeout(closeFeedbackComposer, 2500);
  }
  function showFeedbackError(msg) {
    $fbSubmit.prop('disabled', false).text('Send');
    $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
  }

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

  function closeNoteComposer() {
    $ntComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
    $ntInput.val('');
    $ntSubmit.prop('disabled', true);
    _selRange = null; _selText = ''; _selRect = null;
  }

  function submitNote() {
    var text = $ntInput.val().trim();
    if (!text) return;
    var id    = uid();
    var ts    = nowIso();
    var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
    var span  = wrapSelection(id, 'gra-note-highlight');
    if (span) span.setAttribute('data-gra-quote', quote);
    var note = {id:id, ts:ts, quote:quote, text:text};
    _notes.push(note);
    // CHANGED: was persistNotes() + persistNoteHighlight(). The separate
    // '_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();
    closeNoteComposer();
    openPanel('notes');
  }

  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);
    }
    storeDelete('note', id);
    renderNoteCards();
  }

  function openBookmarkComposer() {
    hideActions();
    if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
    $bmComposer.addClass('gra-composer-visible');
    $backdrop.addClass('gra-backdrop-visible');
    setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
  }

  function closeBookmarkComposer() {
    $bmComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
    $bmInput.val('');
    _selRange = null; _selText = ''; _selRect = null;
  }

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

  function deleteBookmark(id) {
    _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
    var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
    if (span && span.parentNode) {
      var p = span.parentNode;
      while (span.firstChild) p.insertBefore(span.firstChild, span);
      p.removeChild(span);
    }
    storeDelete('bookmark', id);
    renderBookmarkCards();
  }

  function openPanel(tab) {
    _activeTab = tab || _activeTab;
    switchTab(_activeTab);
    $panel.addClass('gra-panel-open');
    $backdrop.addClass('gra-backdrop-visible');
  }
  function closePanel() {
    $panel.removeClass('gra-panel-open');
    $backdrop.removeClass('gra-backdrop-visible');
  }
  function switchTab(tab) {
    _activeTab = tab;
    $tabNotes.toggleClass('gra-tab-active', tab==='notes');
    $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
    $tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes');
    $paneNotes.toggleClass('gra-pane-active', tab==='notes');
    $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
    $paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes');
    if (tab==='notes') renderNoteCards();
    else if (tab==='bookmarks') renderBookmarkCards();
    else renderFootnoteCards();
  }

  function renderNoteCards() {
    if (!_notes.length) {
      $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>');
      return;
    }
    var html = '';
    _notes.slice().reverse().forEach(function(n){
      html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
            + '<div class="gra-card-header">'
            + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>'
            + '<div class="gra-card-meta">'
            + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
            + '</div>'
            + '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
            + '</div>'
            + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
            + '<div class="gra-card-text">'+esc(n.text)+'</div>'
            + '</div>';
    });
    $paneNotes.html(html);
  }

  function renderBookmarkCards() {
    if (!_bookmarks.length) {
      $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>');
      return;
    }
    var html = '';
    _bookmarks.slice().reverse().forEach(function(b){
      html += '<div class="gra-bookmark-card" data-gra-id="'+esc(b.id)+'">'
            + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>'
            + '<div class="gra-bookmark-info">'
            + '<div class="gra-bookmark-name">'+esc(b.name)+'</div>'
            + (b.quote ? '<div class="gra-bookmark-quote">'+esc(b.quote)+'</div>' : '')
            + '</div>'
            + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>'
            + '</div>';
    });
    $paneBookmarks.html(html);
  }

  // Footnotes aren't personal annotations — they're real saved page
  // content (QuickEdit writes .gra-qe-footnotes/.gra-qe-footnote-item
  // into the article HTML). So this reads straight off the live DOM on
  // every open: no fetch, no storage, and deliberately not part of the
  // per-user sync above.
  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;
    }
    var html = '';
    Array.prototype.forEach.call(items, function (li) {
      var numEl = li.querySelector('.gra-qe-footnote-num');
      var quoteEl = li.querySelector('.gra-qe-footnote-quote');
      var textEl = li.querySelector('.gra-qe-footnote-text');
      var num = numEl ? numEl.textContent.trim() : '';
      var quote = quoteEl ? quoteEl.textContent : '';
      var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent);
      var primary = quote ? (num + ' ' + quote) : (num + ' ' + text);
      var id = li.getAttribute('data-gra-id') || '';
      html += '<div class="gra-bookmark-card gra-footnote-card" data-gra-id="'+esc(id)+'">'
            + '<span class="gra-icon gra-icon-footnote" aria-hidden="true"></span>'
            + '<div class="gra-bookmark-info">'
            + '<div class="gra-bookmark-name">'+esc(primary)+'</div>'
            + (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '')
            + '</div>'
            + '</div>';
    });
    $paneFootnotes.html(html);
  }

  function scrollToHighlight(id) {
    var el = document.querySelector('[data-gra-id="'+id+'"]');
    if (!el) return;
    el.scrollIntoView({behavior:'smooth', block:'center'});
    el.classList.add('gra-hl-active');
    setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
  }

  // 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);
  }

  function wireEvents() {

    /* Suppress native context menu inside article content (Android/desktop) */
    document.addEventListener('contextmenu', function(e) {
      var tag = e.target.tagName;
      if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
      var c = document.querySelector(CONTENT_SEL);
      if (c && c.contains(e.target)) e.preventDefault();
    }, { passive: false });

    /* Desktop mouseup */
    $(document).on('mouseup', function(e){
      if (e.button !== 0) return;
      if (_mobile) return;
      setTimeout(tryShowActions, 20);
    });

    /* Separate timers so mobile + desktop never clobber each other */
    var _selTimer    = null;  /* desktop debounce */
    var _mobShowTimer = null; /* mobile show-on-touchend */

    /* Mobile: show fab quickly after finger lifts (selection settled).
       180ms feels instant while still letting the range stabilise. */
    document.addEventListener('touchend', function(e) {
      if (!_mobile) return;
      if ($fab[0] && $fab[0].contains(e.target)) return;
      clearTimeout(_mobShowTimer);
      _mobShowTimer = setTimeout(function() {
        var sel = window.getSelection();
        if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
        tryShowActions();
      }, 180);
    }, { passive: true });

    /* Mobile: only HIDE the fab when selection is cleared while it's visible.
       (Reposition isn't needed now that the bar is docked, and re-running
       showFab here was causing the lag/flicker.) */
    document.addEventListener('selectionchange', function() {
      if (!_mobile) return;
      if (!$fab.hasClass('gra-fab-visible')) return;
      var sel = window.getSelection();
      if (!sel || sel.isCollapsed || !sel.toString().trim()) {
        clearTimeout(_mobShowTimer);
        hideActions();
      }
    });

    /* selectionchange debounced (desktop only) */
    document.addEventListener('selectionchange', function() {
      if (_mobile) return;
      _selVersion++;
      clearTimeout(_selTimer);
      var v = _selVersion;
      _selTimer = setTimeout(function(){
        if (v !== _selVersion) return;
        if (_fabSelVer === v) return;
        tryShowActions();
      }, 600);
    });

    /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */
    $fab[0].addEventListener('touchstart', function(e) {
      _fabTouched = true;
      /* Don't propagate to document handler */
      e.stopPropagation();
    }, { passive: true });

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

    /* ── FAB buttons — use touchend for mobile, click for desktop ── */
    function fabAction(btnId, action) {
      var el = document.getElementById(btnId);
      if (!el) return;
      /* touchend: fires before document touchstart clears _selRange */
      el.addEventListener('touchend', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
        action();
      }, { passive: false });
      /* click: for desktop */
      el.addEventListener('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
        action();
      });
    }

    fabAction('gra-fab-note',     openNoteComposer);
    fabAction('gra-fab-bookmark', openBookmarkComposer);
    fabAction('gra-fab-feedback', openFeedbackComposer);

    document.getElementById('gra-fab-search').addEventListener('touchend', function(e) {
      e.preventDefault(); e.stopPropagation();
      var q = _selText;
      hideActions();
      _selRange = null; _selText = ''; _selRect = null;
      if (q && window.showSearchDialog) { window.showSearchDialog(q); }
    }, { passive: false });
    document.getElementById('gra-fab-search').addEventListener('click', function(e) {
      e.preventDefault(); e.stopPropagation();
      var q = _selText;
      hideActions();
      _selRange = null; _selText = ''; _selRect = null;
      if (q && window.showSearchDialog) { window.showSearchDialog(q); }
      else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
    });

    /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
    (function () {
      var dismissEl = document.getElementById('gra-fab-dismiss');
      if (!dismissEl) return;
      function doDismiss(e) {
        e.preventDefault(); e.stopPropagation();
        hideActions();
        _selRange = null; _selText = ''; _selRect = null;
        if (window.getSelection) {
          var s = window.getSelection();
          if (s && s.removeAllRanges) s.removeAllRanges();
        }
      }
      dismissEl.addEventListener('touchend', doDismiss, { passive: false });
      dismissEl.addEventListener('click', doDismiss);
    }());

    /* Feedback composer */
    $fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); });
    $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer);
    $fbSubmit.on('click', submitFeedback);
    $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); });

    /* Note composer */
    $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); });
    $('#gra-nt-cancel').on('click', closeNoteComposer);
    $ntSubmit.on('click', submitNote);
    $ntInput.on('keydown', function(e){
      if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote();
      if (e.key==='Escape') closeNoteComposer();
    });

    /* Bookmark composer */
    $('#gra-bm-cancel').on('click', closeBookmarkComposer);
    $bmSubmit.on('click', submitBookmark);
    $bmInput.on('keydown', function(e){
      if (e.key==='Enter') submitBookmark();
      if (e.key==='Escape') closeBookmarkComposer();
    });

    /* Panel */
    $('#gra-panel-close').on('click', closePanel);
    $backdrop.on('click touchend', function(e){
      e.preventDefault();
      if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
      else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
      else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
      else closePanel();
    });
    $tabNotes.on('click', function(){ switchTab('notes'); });
    $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
    $tabFootnotes.on('click', function(){ switchTab('footnotes'); });

    /* Panel card → jump to the highlight in the text */
    $paneNotes.on('click', '.gra-note-card', function(e){
      if ($(e.target).hasClass('gra-note-del')) return;
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });
    $paneNotes.on('click', '.gra-note-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteNote(id);
    });
    $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
      if ($(e.target).hasClass('gra-bookmark-del')) return;
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });
    $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteBookmark(id);
    });
    // Footnote highlights carry data-gra-id (see quickedit.js's redesign —
    // plain highlight span, no href/marker element), so this reuses the
    // same scrollToHighlight helper as notes and bookmarks.
    $paneFootnotes.on('click', '.gra-footnote-card', function(){
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });

    /* 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(){
      focusPanelCard($paneNotes, 'notes', $(this).attr('data-gra-id'));
    });
    $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
      focusPanelCard($paneBookmarks, 'bookmarks', $(this).attr('data-gra-id'));
    });
    $(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){
      focusPanelCard($paneFootnotes, 'footnotes', $(this).attr('data-gra-id'));
    });

    // Cross-document links from QuickEdit's Link picker. No real href is
    // ever saved (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, and this handler navigates.
    $(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 window.open's features string
    });

    $(document).on('keydown', function(e){
      if (e.key !== 'Escape') return;
      if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
      else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
      else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
      else closePanel();
    });
  }

  // CHANGED: re-anchors note highlights from _notes directly, rather than
  // from the old separate NT_LS_KEY+'_hl' list. Each note already carries
  // its own quote, so that parallel list was duplicated state — and once
  // notes come from the server, a stale local '_hl' copy would have been
  // an active source of wrong highlights.
  function restoreNoteHighlights() {
    _notes.forEach(function(n){
      if (!n.quote || !n.id) return;
      if (document.querySelector('[data-gra-id="'+n.id+'"].gra-note-highlight')) return;
      var needle = n.quote.replace(/…$/,'').trim().slice(0,80);
      if (!needle) return;
      var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
      if (!range) return;
      var sp = document.createElement('span');
      sp.className = 'gra-note-highlight';
      sp.setAttribute('data-gra-id', n.id);
      sp.setAttribute('data-gra-quote', n.quote);
      try { range.surroundContents(sp); } catch(e){}
    });
  }

  function restoreBookmarkHighlights() {
    _bookmarks.forEach(function(b){
      if (!b.quote) return;
      if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return;
      var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
      if (!needle) return;
      var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
      if (!found) return;
      var sp = document.createElement('span');
      sp.className = 'gra-bookmark-highlight';
      sp.setAttribute('data-gra-id', b.id);
      sp.setAttribute('data-gra-name', b.name);
      try { found.surroundContents(sp); } catch(e){}
    });
  }

  function findTextInContent(root, needle) {
    if (!root || !needle) return null;
    var text = root.textContent || '';
    var idx  = text.indexOf(needle);
    if (idx < 0) return null;
    var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
    var pos = 0, node, startNode, startOffset, endNode, endOffset;
    while ((node = iter.nextNode())) {
      var len = node.nodeValue.length;
      if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; }
      var endIdx = idx + needle.length;
      if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; }
      pos += len;
    }
    if (!startNode || !endNode) return null;
    try {
      var r = document.createRange();
      r.setStart(startNode, startOffset);
      r.setEnd(endNode, endOffset);
      return r;
    } catch(e){ return null; }
  }

  $(function() {
    _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    window.addEventListener('resize', function(){
      _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    });
    buildDom();
    wireEvents();

    // CHANGED: loading is now asynchronous (it may hit the network), so
    // rendering and re-highlighting happen in the callback rather than
    // immediately. Runs on both success and failure — loadPageAnnotations
    // falls back to the local cache internally rather than rejecting, so
    // the page is never left un-highlighted just because the wiki is slow.
    function afterLoad() {
      renderNoteCards();
      renderBookmarkCards();
      setTimeout(function(){
        try { restoreNoteHighlights(); } catch(e){}
        try { restoreBookmarkHighlights(); } catch(e){}
      }, 300);
    }
    loadPageAnnotations().then(afterLoad, afterLoad);
  });

}() );