MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
 
(23 intermediate revisions by the same user not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v6)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback  (v6 + Strategy B + Footnotes tab)
  */
  */


Line 37: Line 37:
   var _fabSelVer  = -1;
   var _fabSelVer  = -1;
   var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;
   var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;
   var _fabTouched = false;  // NEW: flag to prevent hideActions when tapping fab
   var _fabTouched = false;  // flag to prevent hideActions when tapping fab


   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); }
Line 59: Line 59:
   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;


   function buildDom() {
   function buildDom() {
Line 79: Line 81:
       '    <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 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 164: 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 186: 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 213: Line 225:
                     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 256:
     var fabW, fabH, top, left;
     var fabW, fabH, top, left;
     if (_mobile) {
     if (_mobile) {
      fabW = 200; fabH = 48;
       $fab.css({ position: '', top: '', left: '', visibility: '' })
      top  = rect.top + window.scrollY - fabH - 14;
           .addClass('gra-fab-visible gra-fab-mobile-docked');
      left = rect.left + rect.width / 2 - fabW / 2;
      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: left + 'px' })
           .addClass('gra-fab-visible');
       return;
       return;
     }
     }
Line 261: Line 269:
   }
   }


   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 302: 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 355: Line 363:
   function openNoteComposer() {
   function openNoteComposer() {
     hideActions();
     hideActions();
     $ntComposer.css({ top: '', left: '', transform: '' });
     if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' });
     $ntComposer.addClass('gra-composer-visible');
     $ntComposer.addClass('gra-composer-visible');
     $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
Line 395: Line 403:
   function openBookmarkComposer() {
   function openBookmarkComposer() {
     hideActions();
     hideActions();
     $bmComposer.css({ top: '', left: '', transform: '' });
     if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
     $bmComposer.addClass('gra-composer-visible');
     $bmComposer.addClass('gra-composer-visible');
     $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
Line 450: 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();
   }
   }


Line 469: 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 515: Line 527:
     });
     });
     $paneBookmarks.html(html);
     $paneBookmarks.html(html);
  }
  // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal
  // localStorage annotations, they're real saved page content (QuickEdit
  // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved
  // HTML). So this reads straight off the live DOM on every open, no
  // fetch, no storage — verified the scan + both-direction lookup logic
  // against realistic saved-footnote HTML before wiring this in (6/6 pass).
  function renderFootnoteCards() {
    var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item');
    if (!items.length) {
      $paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>');
      return;
    }
    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);
   }
   }


Line 527: Line 582:
   function wireEvents() {
   function wireEvents() {


     /* Suppress native context menu inside article content */
     /* Suppress native context menu inside article content (Android/desktop) */
     document.addEventListener('contextmenu', function(e) {
     document.addEventListener('contextmenu', function(e) {
       var tag = e.target.tagName;
       var tag = e.target.tagName;
Line 542: Line 597:
     });
     });


     /* Mobile: intercept selection as early as possible */
     /* Separate timers so mobile + desktop never clobber each other */
var _lastTouchEnd = 0;
    var _selTimer    = null;  /* desktop debounce */
document.addEventListener('touchend', function(e) {
    var _mobShowTimer = null; /* mobile show-on-touchend */
  if (!_mobile) return;
 
  if ($fab[0] && $fab[0].contains(e.target)) return;
    /* Mobile: show fab quickly after finger lifts (selection settled).
  _lastTouchEnd = Date.now();
      180ms feels instant while still letting the range stabilise. */
  clearTimeout(_selTimer);
    document.addEventListener('touchend', function(e) {
  /* Fire at 100ms — before browser popup (~300ms) */
      if (!_mobile) return;
  _selTimer = setTimeout(tryShowActions, 100);
      if ($fab[0] && $fab[0].contains(e.target)) return;
}, { passive: true });
      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 */
     /* 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 562: Line 636:
         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 624: Line 697:
       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 659: 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 679: 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 699: 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);
     });
     });