MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
 
(31 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v5)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v6 + Strategy B)
* ══════════════════════════════════════════════════════════════════════
*
* FIXES FROM v4
* ─────────────
* • BUG: "Cannot read properties of null (reading 'parentNode')" from
*  ToggleList.js crashing before wireEvents() completes.
*  FIX: wrapSelection() now null-guards every DOM operation. Also
*  wrapped surroundContents in a try/catch that falls back gracefully
*  instead of letting the exception propagate.
*
* • BUG: On mobile, _selRange is null when action buttons are tapped
*  because selectionchange fires again (collapsing the selection) right
*  as the user lifts their finger to tap the action bar.
*  FIX: captureSelection() now saves _selText + a serialized anchor/
*  focus pair at selectionchange time (600ms debounce). We store a
*  *snapshot* of the range so it survives the selection being cleared
*  by the tap on the action button.
*
* • BUG: Mobile bar appeared underneath browser's copy/paste menu.
*  FIX: Delay increased to 700ms and bar z-index lifted to 99999.
*
* • MISC: $mobileBar z-index fixed so it renders above gr-static-bar.
  */
  */


Line 29: 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 39: 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 52: Line 28:
   }
   }


  // ── State ────────────────────────────────────────────────────────────
   var _selRange  = null;
   var _selRange  = null;   // cloned Range — kept alive across taps
   var _selText    = '';
   var _selText    = '';
   var _selRect    = null;
   var _selRect    = null;
Line 62: 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;  // 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 80: 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;
Line 86: Line 60:
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;
   var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;
  // ════════════════════════════════════════════════════════════════════
  // DOM BUILDER
  // ════════════════════════════════════════════════════════════════════


   function buildDom() {
   function buildDom() {
     $fab = $( [
     $fab = $( [
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / 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-feedback" aria-hidden="true"></span>',
      '    <span class="gra-fab-tooltip">Feedback</span>',
      '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Add note">',
       '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Notes</span>',
       '    <span class="gra-fab-btn-label">Note</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">',
       '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Mark">',
       '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Bookmark</span>',
       '    <span class="gra-fab-btn-label">Mark</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Feedback">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Feedback</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search this text">',
       '  <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-icon gra-icon-search" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">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 114: Line 88:
     $('body').append($fab);
     $('body').append($fab);


     $mobileBar = $( [
     $mobileBar = $('<div id="gra-mobile-bar"></div>');
      '<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">Notes</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" id="gra-mob-search" type="button">',
      '      <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
      '      <span class="gra-mob-label">Search</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);


Line 237: Line 186:
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
     });
     });


     $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
     $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
Line 255: Line 203:
   }
   }


  // ════════════════════════════════════════════════════════════════════
  // SELECTION — snapshot stored at selectionchange time
  // ════════════════════════════════════════════════════════════════════
  /**
  * captureSelection()
  * Called from the debounced selectionchange handler (not from button tap).
  * Stores a cloned Range in _selRange so it survives the selection being
  * cleared when the user taps the action bar button.
  */
   function captureSelection() {
   function captureSelection() {
     var sel = window.getSelection();
     var sel = window.getSelection();
Line 276: Line 214:
     if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
     if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
     if (!ancestor || !contentEl.contains(ancestor)) 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;
     _selText  = text;
     _selText  = text;
     _selRect  = range.getBoundingClientRect();
     _selRect  = range.getBoundingClientRect();
    /* Clone the range NOW while selection is live */
     try { _selRange = range.cloneRange(); }
     try { _selRange = range.cloneRange(); }
     catch(e){ _selRange = null; }
     catch(e){ _selRange = null; }
     return true;
     return true;
   }
   }


  /**
  * reCaptureFromDOM()
  * Fallback when _selRange is null at button-tap time (selection already
  * collapsed). Tries to find the text in the DOM using _selText.
  */
   function reCaptureFromDOM() {
   function reCaptureFromDOM() {
     if (!_selText) return false;
     if (!_selText) return false;
Line 306: Line 238:
     if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return;
     if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return;
     if ($bmComposer && $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 (_mobile) {
     showFab(_selRect);
      showMobileBar();
    } else {
      showFab(_selRect);
    }
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // DESKTOP FAB
  // ════════════════════════════════════════════════════════════════════


   function showFab(rect) {
   function showFab(rect) {
    if (_mobile) return;
     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;
      /* Docked as a fixed bar below the reader toolbar.
        All positioning is handled by CSS via .gra-fab-mobile-docked,
        so it never collides with the native selection menu and never
        clips at screen edges or causes horizontal scroll. */
      $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'); }
   function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
 
   function hideActions() { hideFab(); }
  // ════════════════════════════════════════════════════════════════════
  // MOBILE BOTTOM BAR
  // ════════════════════════════════════════════════════════════════════
 
  function showMobileBar() { $mobileBar.addClass('gra-mobile-bar-visible'); }
  function hideMobileBar() { $mobileBar.removeClass('gra-mobile-bar-visible'); }
   function hideActions()   { hideFab(); hideMobileBar(); }
 
  // ════════════════════════════════════════════════════════════════════
  // WRAP SELECTION  — null-safe version
  // ════════════════════════════════════════════════════════════════════


   function wrapSelection(id, cssClass) {
   function wrapSelection(id, cssClass) {
     var range = _selRange;
     var range = _selRange;
     _selRange = null;
     _selRange = null;
     if (!range) return null;
     if (!range) return null;
    /* Verify the range endpoints are still in the document */
     try {
     try {
       if (!document.contains(range.startContainer) ||
       if (!document.contains(range.startContainer) ||
           !document.contains(range.endContainer)) {
           !document.contains(range.endContainer)) return null;
        return null;
      }
     } catch(e) { return null; }
     } catch(e) { return null; }
     function makeSpan() {
     function makeSpan() {
       var sp = document.createElement('span');
       var sp = document.createElement('span');
Line 368: Line 281:
       return sp;
       return sp;
     }
     }
    /* Try surroundContents first (fails if range crosses element boundaries) */
     try {
     try {
       var span = makeSpan();
       var span = makeSpan();
       range.surroundContents(span);
       range.surroundContents(span);
      /* Verify the span was actually inserted */
       if (span.parentNode) return span;
       if (span.parentNode) return span;
     } catch(e) { /* fall through */ }
     } catch(e) {}
 
    /* Fallback: extractContents + re-insert */
     try {
     try {
       var frag = range.extractContents();
       var frag = range.extractContents();
Line 383: Line 291:
       sp2.appendChild(frag);
       sp2.appendChild(frag);
       range.insertNode(sp2);
       range.insertNode(sp2);
      /* null-guard: check parentNode before returning */
       if (sp2 && sp2.parentNode) return sp2;
       if (sp2 && sp2.parentNode) return sp2;
     } catch(e2) { /* give up */ }
     } catch(e2) {}
 
     return null;
     return null;
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // FEEDBACK FLOW
  // ════════════════════════════════════════════════════════════════════


   function openFeedbackComposer() {
   function openFeedbackComposer() {
Line 403: Line 305:
     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 421: Line 323:
     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 438: Line 337:
     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 457: Line 355:
     $('#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();
     $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 484: Line 378:
     var ts    = nowIso();
     var ts    = nowIso();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    /* _selRange may be null if selection was cleared — try re-capture */
     if (!_selRange && _selText) reCaptureFromDOM();
     if (!_selRange && _selText) reCaptureFromDOM();
     var span  = wrapSelection(id, 'gra-note-highlight');
     var span  = wrapSelection(id, 'gra-note-highlight');
Line 502: Line 395:
     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();
     $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 553: Line 442:
     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 577: Line 462:
     else renderBookmarkCards();
     else renderBookmarkCards();
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RENDER CARDS
  // ════════════════════════════════════════════════════════════════════


   function renderNoteCards() {
   function renderNoteCards() {
Line 591: Line 472:
       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 638: Line 519:
     $paneBookmarks.html(html);
     $paneBookmarks.html(html);
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // SCROLL TO HIGHLIGHT
  // ════════════════════════════════════════════════════════════════════


   function scrollToHighlight(id) {
   function scrollToHighlight(id) {
Line 651: Line 528:
   }
   }


   // ════════════════════════════════════════════════════════════════════
   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;
Line 664: Line 545:
     });
     });


     /* ── Mobile: touchend triggers selection check ──
     /* Separate timers so mobile + desktop never clobber each other */
    * On Minerva, selectionchange fires during drag but the range
    var _selTimer    = null;  /* desktop debounce */
    * isn't stable until touchend. We listen for touchend too and
    var _mobShowTimer = null; /* mobile show-on-touchend */
    * check after a short delay for the selection to settle.        */
 
     document.addEventListener('touchend', function() {
    /* 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 (!_mobile) return;
       clearTimeout(_selTimer);
      if ($fab[0] && $fab[0].contains(e.target)) return;
       _selTimer = setTimeout(tryShowActions, 400);
       clearTimeout(_mobShowTimer);
       _mobShowTimer = setTimeout(function() {
        var sel = window.getSelection();
        if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
        tryShowActions();
      }, 180);
     }, { passive: true });
     }, { passive: true });


     /* ── selectionchange debounced 600ms ── */
     /* Mobile: only HIDE the fab when selection is cleared while it's visible.
     var _selTimer = null;
      (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() {
     document.addEventListener('selectionchange', function() {
      if (_mobile) return;
       _selVersion++;
       _selVersion++;
       clearTimeout(_selTimer);
       clearTimeout(_selTimer);
Line 687: Line 588:
     });
     });


     /* ── 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 ── */
     /* ── FAB buttons — use touchend for mobile, click for desktop ── */
     $('#gra-fab-feedback').on('click', function(e){
     function fabAction(btnId, action) {
       e.preventDefault(); e.stopPropagation();
       var el = document.getElementById(btnId);
       if (!_selRange) captureSelection();
       if (!el) return;
       if (!_selRange) return;
       /* touchend: fires before document touchstart clears _selRange */
       openFeedbackComposer();
       el.addEventListener('touchend', function(e) {
    });
        e.preventDefault();
    $('#gra-fab-note').on('click', function(e){
        e.stopPropagation();
      e.preventDefault(); e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
      if (!_selRange) captureSelection();
        action();
      if (!_selRange) return;
      }, { passive: false });
      openNoteComposer();
      /* click: for desktop */
    });
      el.addEventListener('click', function(e) {
    $('#gra-fab-bookmark').on('click', function(e){
        e.preventDefault();
      e.preventDefault(); e.stopPropagation();
        e.stopPropagation();
      if (!_selRange) captureSelection();
        if (!_selRange && !reCaptureFromDOM()) return;
       if (!_selRange) return;
        action();
      openBookmarkComposer();
       });
     });
    }
     $('#gra-fab-search').on('click', function(e){
 
    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();
       var q = _selText;
       var q = _selText;
Line 723: Line 636:
       _selRange = null; _selText = ''; _selRect = null;
       _selRange = null; _selText = ''; _selRect = null;
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
      else if (q) { /* trigger readerToolbar search shortcut */ $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
     }, { passive: false });
     });
     document.getElementById('gra-fab-search').addEventListener('click', function(e) {
 
    /* ── Mobile bottom bar ──
    * _selRange was already stored when selectionchange fired 700ms
    * earlier. We use it directly — no need to call captureSelection()
    * again (selection is likely already collapsed from the tap).    */
    $('#gra-mob-feedback').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      hideMobileBar();
      if (!_selRange && !reCaptureFromDOM()) return;
      openFeedbackComposer();
    });
     $('#gra-mob-note').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      hideMobileBar();
      if (!_selRange && !reCaptureFromDOM()) return;
      openNoteComposer();
    });
    $('#gra-mob-bookmark').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      hideMobileBar();
      if (!_selRange && !reCaptureFromDOM()) return;
      openBookmarkComposer();
    });
    $('#gra-mob-search').on('click touchend', function(e){
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       var q = _selText;
       var q = _selText;
       hideMobileBar();
       hideActions();
       _selRange = null; _selText = ''; _selRect = null;
       _selRange = null; _selText = ''; _selRect = null;
      if (window.getSelection) window.getSelection().removeAllRanges();
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
       if (q && window.showSearchDialog) { setTimeout(function(){ window.showSearchDialog(q); }, 50); }
      else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
    });
    $('#gra-mob-dismiss').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      hideMobileBar();
      _selRange = null; _selText = ''; _selRect = null;
      if (window.getSelection) window.getSelection().removeAllRanges();
     });
     });


     /* ── Feedback composer ── */
     /* ── 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()); });
     $fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); });
     $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer);
     $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer);
Line 769: Line 669:
     $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); });
     $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); });


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


     /* ── 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 786: Line 686:
     });
     });


     /* ── 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 848: Line 748:
     });
     });
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RESTORE HIGHLIGHTS
  // ════════════════════════════════════════════════════════════════════


   function persistNoteHighlight(id, quote) {
   function persistNoteHighlight(id, quote) {
Line 875: Line 771:
       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){ /* skip if crossing boundaries */ }
       try { range.surroundContents(sp); } catch(e){}
     });
     });
   }
   }
Line 917: Line 813:
     } catch(e){ return null; }
     } catch(e){ return null; }
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // BOOT
  // ════════════════════════════════════════════════════════════════════


   $(function() {
   $(function() {
Line 927: Line 819:
       _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
       _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
     });
     });
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();
     loadNotes();
     loadNotes();
     loadBookmarks();
     loadBookmarks();
    /* Restore highlights in a setTimeout so they don't block page paint */
     setTimeout(function(){
     setTimeout(function(){
       try { restoreNoteHighlights(); } catch(e){}
       try { restoreNoteHighlights(); } catch(e){}