MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
 
(44 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v4)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback  (v6 + Strategy B + Footnotes tab)
* ══════════════════════════════════════════════════════════════════════
*
* CHANGES FROM v3
* ────────────────
* • Mobile: FAB no longer fights browser's native copy/paste menu.
*  On mobile, a bottom-sheet action bar slides up after selection
*  instead of a tiny floating strip next to the text.
* • Mobile: Long-press detection improved — waits for selectionchange
*  to settle before showing the action bar.
* • Mobile: Action bar buttons are large (48px tap targets) with labels.
* • Desktop: FAB strip unchanged — appears beside selection.
* • Feedback composer: centered modal on all screen sizes.
  */
  */


Line 19: Line 7:
   'use strict';
   'use strict';


  // ── Configuration ────────────────────────────────────────────────────
   var CONTENT_SEL  = '#mw-content-text';
   var CONTENT_SEL  = '#mw-content-text';
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
Line 29: Line 16:


   if ( currentUser && window.mw ) {
   if ( currentUser && window.mw ) {
     new mw.Api().get({
     new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 })
      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;
      } ).catch( function () {} );
    } ).catch( function () {} );
   }
   }


Line 42: Line 28:
   }
   }


  // ── State ────────────────────────────────────────────────────────────
   var _selRange  = null;
   var _selRange  = null;
   var _selText    = '';
   var _selText    = '';
Line 51: Line 36:
   var _selVersion = 0;
   var _selVersion = 0;
   var _fabSelVer  = -1;
   var _fabSelVer  = -1;
   var _mobile    = false;   // set on init
   var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;
  var _fabTouched = false; // flag to prevent hideActions when tapping fab


  // ── Helpers ──────────────────────────────────────────────────────────
   function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
   function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
   function esc(s) {
   function esc(s) {
Line 70: Line 55:
   function isMobile() { return _mobile; }
   function isMobile() { return _mobile; }


  // ── DOM references ───────────────────────────────────────────────────
   var $fab, $mobileBar, $panel, $backdrop;
   var $fab, $mobileBar, $panel, $backdrop;
   var $ntComposer, $ntInput, $ntSubmit;
   var $ntComposer, $ntInput, $ntSubmit;
   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;
  // CHANGED: added $tabFootnotes / $paneFootnotes alongside the existing
 
  // Notes/Bookmarks tab+pane refs, for the new Footnotes tab.
  // ════════════════════════════════════════════════════════════════════
   var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes;
  // DOM BUILDER
  // ════════════════════════════════════════════════════════════════════


   function buildDom() {
   function buildDom() {
    // ── Desktop FAB strip (hidden on mobile) ─────────────────────────
     $fab = $( [
     $fab = $( [
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">',
       '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">',
      '  <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-icon gra-icon-feedback" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Feedback</span>',
       '    <span class="gra-fab-btn-label">Feedback</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Add note">',
       '  <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search">',
       '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Note</span>',
       '    <span class="gra-fab-btn-label">Search</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">',
       '  <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">',
       '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Bookmark</span>',
       '    <span class="gra-fab-btn-label">Close</span>',
       '  </button>',
       '  </button>',
       '</div>',
       '</div>',
Line 101: Line 90:
     $('body').append($fab);
     $('body').append($fab);


    // ── Mobile bottom action bar (shown instead of FAB on mobile) ────
     $mobileBar = $('<div id="gra-mobile-bar"></div>');
    // Slides up from the bottom — well above the browser's copy/paste menu
    // Large tap targets (48px+) with text labels
     $mobileBar = $( [
      '<div id="gra-mobile-bar" role="toolbar" aria-label="Actions">',
      ' <div id="gra-mobile-bar-inner">',
      '    <button class="gra-mob-btn" id="gra-mob-feedback" type="button">',
      '      <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '      <span class="gra-mob-label">Feedback</span>',
      '    </button>',
      '    <button class="gra-mob-btn" id="gra-mob-note" type="button">',
      '      <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
      '      <span class="gra-mob-label">Note</span>',
      '    </button>',
      '    <button class="gra-mob-btn" id="gra-mob-bookmark" type="button">',
      '      <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
      '      <span class="gra-mob-label">Bookmark</span>',
      '    </button>',
      '    <button class="gra-mob-btn gra-mob-dismiss" id="gra-mob-dismiss" type="button">',
      '      <span style="font-size:20px;line-height:1">✕</span>',
      '      <span class="gra-mob-label">Dismiss</span>',
      '    </button>',
      '  </div>',
      '</div>',
    ].join('') );
     $('body').append($mobileBar);
     $('body').append($mobileBar);


    // ── Feedback composer (centered modal) ───────────────────────────
     $fbComposer = $( [
     $fbComposer = $( [
       '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
       '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
Line 159: Line 123:
     $('body').append($fbComposer);
     $('body').append($fbComposer);


    // ── Note composer ────────────────────────────────────────────────
     $ntComposer = $( [
     $ntComposer = $( [
       '<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">',
       '<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">',
       '  <div class="gra-composer-user">',
       '  <div class="gra-composer-user">',
       '    <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
       '    <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Note') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Notes') + '</div>',
       '  </div>',
       '  </div>',
       '  <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>',
       '  <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>',
Line 175: Line 138:
     $('body').append($ntComposer);
     $('body').append($ntComposer);


    // ── Bookmark composer ─────────────────────────────────────────────
     $bmComposer = $( [
     $bmComposer = $( [
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
Line 191: Line 153:
     $('body').append($bmComposer);
     $('body').append($bmComposer);


    // ── Right panel ───────────────────────────────────────────────────
     $panel = $( [
     $panel = $( [
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
Line 204: Line 165:
       '    <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 209: Line 173:
       '    <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 231: Line 196:
     $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 243: Line 210:
     $fbQuote      = $('#gra-fb-quote');
     $fbQuote      = $('#gra-fb-quote');
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // SELECTION
  // ════════════════════════════════════════════════════════════════════


   function captureSelection() {
   function captureSelection() {
Line 256: Line 219:
     var contentEl = document.querySelector(CONTENT_SEL);
     var contentEl = document.querySelector(CONTENT_SEL);
     if (!contentEl) return false;
     if (!contentEl) return false;
     var start = range.commonAncestorContainer;
     var ancestor = range.commonAncestorContainer;
     if (start.nodeType === 3) start = start.parentNode;
     if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
     if (!contentEl.contains(start)) return false;
     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;
     _selText  = text;
    _selRange = range.cloneRange();
     _selRect  = range.getBoundingClientRect();
     _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;
     return true;
   }
   }


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


   function showFab(rect) {
   function showFab(rect) {
     if (!rect) return;
     if (!rect) return;
     var fabW = 46, fabH = 126;
     var fabW, fabH, top, left;
     var top  = rect.top + (rect.height/2) - (fabH/2);
    if (_mobile) {
     var left = rect.right + 10;
      $fab.css({ position: '', top: '', left: '', visibility: '' })
          .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;
     if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10;
     top  = clamp(top,  8, window.innerHeight - fabH - 8);
     top  = clamp(top,  8, window.innerHeight - fabH - 8);
     left = clamp(left, 8, window.innerWidth  - fabW - 8);
     left = clamp(left, 8, window.innerWidth  - fabW - 8);
     $fab.css({top: top+'px', left: left+'px'}).addClass('gra-fab-visible');
     $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
  }
 
  function hideFab() { $fab.removeClass('gra-fab-visible'); }
 
  // ════════════════════════════════════════════════════════════════════
  // MOBILE BOTTOM BAR
  // ════════════════════════════════════════════════════════════════════
 
  function showMobileBar() {
    $mobileBar.addClass('gra-mobile-bar-visible');
  }
 
  function hideMobileBar() {
    $mobileBar.removeClass('gra-mobile-bar-visible');
  }
 
  function hideActions() {
    hideFab();
    hideMobileBar();
   }
   }


  // ════════════════════════════════════════════════════════════════════
   function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
  // COMPOSER POSITIONING (desktop note/bookmark only)
   function hideActions() { hideFab(); }
  // ════════════════════════════════════════════════════════════════════
 
   function positionComposer($el) {
    if (isMobile()) {
      // On mobile always center — don't anchor to selection
      $el.css({top: '', left: '', transform: ''});
      return;
    }
    if (!_selRect) return;
    var W   = 340;
    var top  = _selRect.bottom + 8;
    var left = _selRect.left;
    if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
    left = Math.max(left, 8);
    if (top + 280 > window.innerHeight) top = _selRect.top - 290;
    top  = Math.max(top, 8);
    $el.css({top: top+'px', left: left+'px'});
  }
 
  // ════════════════════════════════════════════════════════════════════
  // WRAP SELECTION
  // ════════════════════════════════════════════════════════════════════


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


   function openFeedbackComposer() {
   function openFeedbackComposer() {
Line 376: Line 310:
     if (currentUserEmail) $fbEmail.val(currentUserEmail);
     if (currentUserEmail) $fbEmail.val(currentUserEmail);
     else $fbEmail.val('');
     else $fbEmail.val('');
     $fbComposer.css({top:'', left:'', transform:''});
     if (!_mobile) $fbComposer.css({top:'', left:'', transform:''});
     $fbComposer.addClass('gra-composer-visible');
     $fbComposer.addClass('gra-composer-visible');
     $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
Line 394: Line 328:
     var quote    = $fbQuote.text();
     var quote    = $fbQuote.text();
     if (!issueType) return;
     if (!issueType) return;
     $fbSubmit.prop('disabled', true).text('Sending…');
     $fbSubmit.prop('disabled', true).text('Sending…');
     $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
     $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
     var issueLabels = {
     var issueLabels = {
       wrong_text: 'Formatting error', reference_issue: 'Reference issue',
       wrong_text: 'Formatting error', reference_issue: 'Reference issue',
       spelling_mistake: 'Spelling mistake', other: 'Other'
       spelling_mistake: 'Spelling mistake', other: 'Other'
     };
     };
     var payload = new FormData();
     var payload = new FormData();
     payload.append('issue_type',    issueLabels[issueType] || issueType);
     payload.append('issue_type',    issueLabels[issueType] || issueType);
Line 411: Line 342:
     payload.append('user_email',    email || currentUserEmail || '');
     payload.append('user_email',    email || currentUserEmail || '');
     payload.append('wiki_user',    currentUser || 'anonymous');
     payload.append('wiki_user',    currentUser || 'anonymous');
     fetch('/feedback.php', {method:'POST', body:payload})
     fetch('/feedback.php', {method:'POST', body:payload})
       .then(function(r){ return r.json(); })
       .then(function(r){ return r.json(); })
Line 426: Line 356:
     setTimeout(closeFeedbackComposer, 2500);
     setTimeout(closeFeedbackComposer, 2500);
   }
   }
   function showFeedbackError(msg) {
   function showFeedbackError(msg) {
     $fbSubmit.prop('disabled', false).text('Send');
     $fbSubmit.prop('disabled', false).text('Send');
     $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
     $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // NOTE FLOW
  // ════════════════════════════════════════════════════════════════════


   function openNoteComposer() {
   function openNoteComposer() {
     hideActions();
     hideActions();
     positionComposer($ntComposer);
     if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' });
     $ntComposer.addClass('gra-composer-visible');
     $ntComposer.addClass('gra-composer-visible');
     if (isMobile()) $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
   }
   }
Line 458: Line 383:
     var ts    = nowIso();
     var ts    = nowIso();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
     var span  = wrapSelection(id, 'gra-note-highlight');
     var span  = wrapSelection(id, 'gra-note-highlight');
     if (span) span.setAttribute('data-gra-quote', quote);
     if (span) span.setAttribute('data-gra-quote', quote);
Line 474: Line 400:
     try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
     try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // BOOKMARK FLOW
  // ════════════════════════════════════════════════════════════════════


   function openBookmarkComposer() {
   function openBookmarkComposer() {
     hideActions();
     hideActions();
     positionComposer($bmComposer);
     if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
     $bmComposer.addClass('gra-composer-visible');
     $bmComposer.addClass('gra-composer-visible');
     if (isMobile()) $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
     setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
   }
   }
Line 498: Line 420:
     var id    = uid();
     var id    = uid();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
     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); }
Line 510: Line 433:
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     if (span) {
     if (span && span.parentNode) {
       var p = span.parentNode;
       var p = span.parentNode;
       while (span.firstChild) p.insertBefore(span.firstChild, span);
       while (span.firstChild) p.insertBefore(span.firstChild, span);
Line 524: Line 447:
     try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
     try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // PANEL
  // ════════════════════════════════════════════════════════════════════


   function openPanel(tab) {
   function openPanel(tab) {
Line 539: Line 458:
     $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;
     $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();
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RENDER CARDS
  // ════════════════════════════════════════════════════════════════════


   function renderNoteCards() {
   function renderNoteCards() {
Line 562: Line 481:
       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 578: Line 497:
     _notes = _notes.filter(function(n){ return n.id !== id; });
     _notes = _notes.filter(function(n){ return n.id !== id; });
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
     if (span) {
     if (span && span.parentNode) {
       var p = span.parentNode;
       var p = span.parentNode;
       while (span.firstChild) p.insertBefore(span.firstChild, span);
       while (span.firstChild) p.insertBefore(span.firstChild, span);
Line 610: Line 529:
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal
   // SCROLL TO HIGHLIGHT
  // localStorage annotations, they're real saved page content (QuickEdit
   // ════════════════════════════════════════════════════════════════════
  // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved
  // HTML). So this reads straight off the live DOM on every open, no
  // fetch, no storage — verified the scan + both-direction lookup logic
   // against realistic saved-footnote HTML before wiring this in (6/6 pass).
   function renderFootnoteCards() {
    var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item');
    if (!items.length) {
      $paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>');
      return;
    }
    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() : '';
      // CHANGED: was showing the NUMBER as the card's primary line and the
      // footnote's own note text as secondary — nowhere did it show WHICH
      // text in the document the footnote actually belongs to, which was
      // the whole point of being able to identify one footnote from
      // another. Now shows the referenced quote as primary (matching how
      // Notes/Bookmarks already identify themselves by quote, not by an
      // arbitrary id), with the number as a small prefix and the
      // footnote's own note text as secondary. Falls back to the note
      // text alone if a footnote predates this change and has no quote
      // span at all, rather than showing nothing.
      var quote = quoteEl ? quoteEl.textContent : '';
      var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent);
      var primary = quote ? (num + ' ' + quote) : (num + ' ' + text);
      var id = li.getAttribute('data-gra-id') || '';
      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) {
   function scrollToHighlight(id) {
Line 622: Line 580:
   }
   }


   // ════════════════════════════════════════════════════════════════════
   function wireEvents() {
  // EVENT WIRING
  // ════════════════════════════════════════════════════════════════════


  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 ───────────────────────────────────────────
     /* Desktop mouseup */
     $(document).on('mouseup', function(e){
     $(document).on('mouseup', function(e){
       if (e.button !== 0) return;
       if (e.button !== 0) return;
       if (isMobile()) return;   // mobile handled separately
       if (_mobile) return;
       setTimeout(tryShowActions, 20);
       setTimeout(tryShowActions, 20);
     });
     });


     // ── Mobile: selectionchange (debounced 600ms) ─────────────────
     /* Separate timers so mobile + desktop never clobber each other */
     // We wait longer than v3 so the browser's own copy menu has time
     var _selTimer    = null;  /* desktop debounce */
     // to appear first — user can still copy, THEN our bar slides up.
     var _mobShowTimer = null; /* mobile show-on-touchend */
    var _selTimer = null;
 
    /* 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() {
     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++;
       _selVersion++;
       clearTimeout(_selTimer);
       clearTimeout(_selTimer);
Line 646: Line 636:
         if (v !== _selVersion) return;
         if (v !== _selVersion) return;
         if (_fabSelVer === v) return;
         if (_fabSelVer === v) return;
        if (!isMobile()) return;  // desktop uses mouseup
         tryShowActions();
         tryShowActions();
       }, 600);   // 600ms — after browser copy menu appears
       }, 600);
     });
     });


     // ── Click outside → hide actions ──────────────────────────────
     /* ── 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){
     $(document).on('mousedown touchstart', function(e){
      if (_fabTouched) { _fabTouched = false; return; }
       var t = e.target;
       var t = e.target;
       if ($fab[0]         && $fab[0].contains(t))         return;
       if ($fab[0]       && $fab[0].contains(t))       return;
      if ($mobileBar[0]  && $mobileBar[0].contains(t))  return;
       if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
       if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
       if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
       if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
       if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
       if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
       hideActions();
       hideActions();
     });
     });


     // ── Desktop FAB buttons ───────────────────────────────────────
     /* ── FAB buttons — use touchend for mobile, click for desktop ── */
     $('#gra-fab-feedback').on('click', function(e){
    function fabAction(btnId, action) {
      var el = document.getElementById(btnId);
      if (!el) return;
      /* touchend: fires before document touchstart clears _selRange */
      el.addEventListener('touchend', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
        action();
      }, { passive: false });
      /* click: for desktop */
      el.addEventListener('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
        action();
      });
    }
 
     fabAction('gra-fab-note',    openNoteComposer);
    fabAction('gra-fab-bookmark', openBookmarkComposer);
    fabAction('gra-fab-feedback', openFeedbackComposer);
 
    document.getElementById('gra-fab-search').addEventListener('touchend', function(e) {
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if (!_selRange && !captureSelection()) return;
      var q = _selText;
      openFeedbackComposer();
      hideActions();
     });
      _selRange = null; _selText = ''; _selRect = null;
     $('#gra-fab-note').on('click', function(e){
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
     }, { passive: false });
     document.getElementById('gra-fab-search').addEventListener('click', function(e) {
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if (!_selRange && !captureSelection()) return;
       var q = _selText;
       openNoteComposer();
       hideActions();
    });
      _selRange = null; _selText = ''; _selRect = null;
    $('#gra-fab-bookmark').on('click', function(e){
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
       e.preventDefault(); e.stopPropagation();
       else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
       if (!_selRange && !captureSelection()) return;
      openBookmarkComposer();
     });
     });


     // ── Mobile bottom bar buttons ─────────────────────────────────
     /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
     $('#gra-mob-feedback').on('click touchend', function(e){
     (function () {
       e.preventDefault(); e.stopPropagation();
       var dismissEl = document.getElementById('gra-fab-dismiss');
      hideMobileBar();
       if (!dismissEl) return;
       if (!_selRange && !captureSelection()) return;
       function doDismiss(e) {
       openFeedbackComposer();
        e.preventDefault(); e.stopPropagation();
    });
        hideActions();
    $('#gra-mob-note').on('click touchend', function(e){
        _selRange = null; _selText = ''; _selRect = null;
      e.preventDefault(); e.stopPropagation();
        if (window.getSelection) {
      hideMobileBar();
          var s = window.getSelection();
      if (!_selRange && !captureSelection()) return;
          if (s && s.removeAllRanges) s.removeAllRanges();
      openNoteComposer();
        }
    });
       }
    $('#gra-mob-bookmark').on('click touchend', function(e){
      dismissEl.addEventListener('touchend', doDismiss, { passive: false });
      e.preventDefault(); e.stopPropagation();
       dismissEl.addEventListener('click', doDismiss);
      hideMobileBar();
     }());
      if (!_selRange && !captureSelection()) return;
       openBookmarkComposer();
    });
    $('#gra-mob-dismiss').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
       hideMobileBar();
      // Clear selection visually
      if (window.getSelection) window.getSelection().removeAllRanges();
     });


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


     // ── Note composer ─────────────────────────────────────────────
     /* Note composer */
     $ntInput.on('input', function(){
     $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); });
      $ntSubmit.prop('disabled', !$(this).val().trim());
    });
     $('#gra-nt-cancel').on('click', closeNoteComposer);
     $('#gra-nt-cancel').on('click', closeNoteComposer);
     $ntSubmit.on('click', submitNote);
     $ntSubmit.on('click', submitNote);
Line 728: Line 730:
     });
     });


     // ── Bookmark composer ─────────────────────────────────────────
     /* Bookmark composer */
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
     $bmSubmit.on('click', submitBookmark);
     $bmSubmit.on('click', submitBookmark);
Line 736: Line 738:
     });
     });


     // ── Panel ─────────────────────────────────────────────────────
     /* Panel */
     $('#gra-panel-close').on('click', closePanel);
     $('#gra-panel-close').on('click', closePanel);
     $backdrop.on('click touchend', function(e){
     $backdrop.on('click touchend', function(e){
Line 747: Line 749:
     $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'); });


     $paneNotes.on('click', '.gra-note-card', function(e){
     $paneNotes.on('click', '.gra-note-card', function(e){
Line 767: Line 771:
       var id = $(this).attr('data-del-id');
       var id = $(this).attr('data-del-id');
       if (id) deleteBookmark(id);
       if (id) deleteBookmark(id);
    });
    // CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's
    // redesign — plain highlight span, no href/marker element anymore), so
    // this can reuse the EXACT same scrollToHighlight(id) helper Notes/
    // Bookmarks already use below, instead of custom href-parsing logic
    // that broke every time the marker format changed.
    $paneFootnotes.on('click', '.gra-footnote-card', function(){
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
     });
     });


Line 787: Line 800:
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
         if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
         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(){
      var id = $(this).attr('data-gra-id');
      openPanel('footnotes');
      setTimeout(function(){
        var $card = $paneFootnotes.find('[data-gra-id="'+id+'"]');
        if ($card.length) {
          $card.addClass('gra-card-active');
          $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
        }
       }, 100);
       }, 100);
     });
     });
Line 798: Line 827:
     });
     });
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RESTORE HIGHLIGHTS
  // ════════════════════════════════════════════════════════════════════


   function persistNoteHighlight(id, quote) {
   function persistNoteHighlight(id, quote) {
Line 821: Line 846:
       if (!needle) return;
       if (!needle) return;
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       if (range) {
       if (!range) return;
        var sp = document.createElement('span');
      var sp = document.createElement('span');
        sp.className = 'gra-note-highlight';
      sp.className = 'gra-note-highlight';
        sp.setAttribute('data-gra-id', h.id);
      sp.setAttribute('data-gra-id', h.id);
        try { range.surroundContents(sp); } catch(e){}
      try { range.surroundContents(sp); } catch(e){}
      }
     });
     });
   }
   }
Line 837: Line 861:
       if (!needle) return;
       if (!needle) return;
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       if (found) {
       if (!found) return;
        var sp = document.createElement('span');
      var sp = document.createElement('span');
        sp.className = 'gra-bookmark-highlight';
      sp.className = 'gra-bookmark-highlight';
        sp.setAttribute('data-gra-id', b.id);
      sp.setAttribute('data-gra-id', b.id);
        sp.setAttribute('data-gra-name', b.name);
      sp.setAttribute('data-gra-name', b.name);
        try { found.surroundContents(sp); } catch(e){}
      try { found.surroundContents(sp); } catch(e){}
      }
     });
     });
   }
   }


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


   $(function() {
   $(function() {
    // Detect mobile once on load
     _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
     _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
     window.addEventListener('resize', function(){
     window.addEventListener('resize', function(){
       _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
       _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
     });
     });
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();
     loadNotes();
     loadNotes();
     loadBookmarks();
     loadBookmarks();
     restoreNoteHighlights();
     setTimeout(function(){
    restoreBookmarkHighlights();
      try { restoreNoteHighlights(); } catch(e){}
      try { restoreBookmarkHighlights(); } catch(e){}
    }, 500);
   });
   });


}() );
}() );