Jump to content

MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

From Anandamakaranda
No edit summary
No edit summary
 
(30 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v5-patch1)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v6 + Strategy B)
* ═════════════════════════════════════════════════════════════════════
  */
  */


Line 8: 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 18: 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 31: 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 41: 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 59: 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 65: 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 93: 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 216: 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 234: 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 255: 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;
    /* ── Editor mode guard (v5-patch1) ────────────────────────────
    * Do not fire the annotation gadget when the selection is inside
    * the custom editor surface (#se-surface / .se-outer).
    * This is a purely additive check — no existing logic is changed.
    * ──────────────────────────────────────────────────────────── */
     var _editorEl = document.getElementById('se-surface') ||
     var _editorEl = document.getElementById('se-surface') ||
                     document.querySelector('.se-outer');
                     document.querySelector('.se-outer');
     if ( _editorEl && _editorEl.contains(ancestor) ) return false;
     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 294: 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 356: 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 371: 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 391: 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 409: 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 426: 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 445: 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 472: 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 490: 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 541: 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 565: Line 462:
     else renderBookmarkCards();
     else renderBookmarkCards();
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RENDER CARDS
  // ════════════════════════════════════════════════════════════════════


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


   function scrollToHighlight(id) {
   function scrollToHighlight(id) {
Line 639: 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 652: 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() {
     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 675: 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 711: 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 757: 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 766: 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 774: 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 836: Line 748:
     });
     });
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RESTORE HIGHLIGHTS
  // ════════════════════════════════════════════════════════════════════


   function persistNoteHighlight(id, quote) {
   function persistNoteHighlight(id, quote) {
Line 863: 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 905: Line 813:
     } catch(e){ return null; }
     } catch(e){ return null; }
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // BOOT
  // ════════════════════════════════════════════════════════════════════


   $(function() {
   $(function() {
Line 915: 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){}

Latest revision as of 19:07, 12 June 2026

/**
 * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v6 + Strategy B)
 */

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

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

  if ( currentUser && window.mw ) {
    new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 })
      .then( function (data) {
        var info = data && data.query && data.query.userinfo;
        if ( info && info.email ) currentUserEmail = info.email;
      } ).catch( function () {} );
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  function showFab(rect) {
    if (!rect) return;
    var fabW, fabH, top, left;
    if (_mobile) {
      /* 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;
    top  = clamp(top,  8, window.innerHeight - fabH - 8);
    left = clamp(left, 8, window.innerWidth  - fabW - 8);
    $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
  }

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

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

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

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

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

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

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

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

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

  function persistNotes() {
    try { localStorage.setItem(NT_LS_KEY, JSON.stringify(_notes)); } catch(e){}
  }
  function loadNotes() {
    try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
  }

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

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

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

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

  function persistBookmarks() {
    try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){}
  }
  function loadBookmarks() {
    try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
  }

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

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

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

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

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

  function wireEvents() {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    $paneNotes.on('click', '.gra-note-card', function(e){
      if ($(e.target).hasClass('gra-note-del')) return;
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });
    $paneNotes.on('click', '.gra-note-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteNote(id);
    });
    $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
      if ($(e.target).hasClass('gra-bookmark-del')) return;
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });
    $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteBookmark(id);
    });

    $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
      var id = $(this).attr('data-gra-id');
      openPanel('notes');
      setTimeout(function(){
        var $card = $paneNotes.find('[data-gra-id="'+id+'"]');
        if ($card.length) {
          $card.addClass('gra-card-active');
          $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
        }
      }, 100);
    });
    $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
      var id = $(this).attr('data-gra-id');
      openPanel('bookmarks');
      setTimeout(function(){
        var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
        if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
      }, 100);
    });

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

  function persistNoteHighlight(id, quote) {
    try {
      var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
      s = s.filter(function(h){ return h.id !== id; });
      s.push({id:id, quote:quote});
      localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
    } catch(e){}
  }

  function restoreNoteHighlights() {
    var s = [];
    try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){}
    s.forEach(function(h){
      if (!h.quote || !h.id) return;
      if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return;
      var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
      if (!needle) return;
      var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
      if (!range) return;
      var sp = document.createElement('span');
      sp.className = 'gra-note-highlight';
      sp.setAttribute('data-gra-id', h.id);
      try { range.surroundContents(sp); } catch(e){}
    });
  }

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

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

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

}() );