MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v3)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v4)
  * ══════════════════════════════════════════════════════════════════════
  * ══════════════════════════════════════════════════════════════════════
  *
  *
  * CHANGES FROM v2
  * CHANGES FROM v3
  * ────────────────
  * ────────────────
  * • "Comments" renamed to "Notes" throughout. Notes are user-local
  * • Mobile: FAB no longer fights browser's native copy/paste menu.
  *  (localStorage only, not saved to wiki Talk pages).
  *  On mobile, a bottom-sheet action bar slides up after selection
* • Comment icon replaced with notes.svg (same path convention).
  *  instead of a tiny floating strip next to the text.
* • New "Feedback" button (flag icon) replaces the old comment FAB button.
  * • Mobile: Long-press detection improved — waits for selectionchange
  *  Shows a popup with:
  *  to settle before showing the action bar.
*    - Highlighted text (read-only)
  * • Mobile: Action bar buttons are large (48px tap targets) with labels.
  *     - Issue type: Wrong text / Reference issue / Spelling mistake / Other
  * • Desktop: FAB strip unchanged — appears beside selection.
*    - Free-text area
  * • Feedback composer: centered modal on all screen sizes.
*    - Email field for user's address
  *  Submits via EmailJS / fetch to admin@anandamakaranda.com.
  *   NOT stored anywhere on the wiki. NOT shown in the panel.
  * • Notes panel tab uses notes.svg icon.
  * ══════════════════════════════════════════════════════════════════════
  */
  */


Line 25: Line 20:


   // ── Configuration ────────────────────────────────────────────────────
   // ── Configuration ────────────────────────────────────────────────────
  var ADMIN_EMAIL  = 'admin@anandamakaranda.com';
   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 32: Line 26:
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   var currentUserEmail = '';   // fetched from MW API for logged-in users
   var currentUserEmail = '';


  // Fetch the logged-in user's email address from MW API
   if ( currentUser && window.mw ) {
   if ( currentUser && window.mw ) {
     new mw.Api().get({
     new mw.Api().get({
       action: 'query',
       action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2,
      meta:   'userinfo',
     }).then( function (data) {
      uiprop: 'email',
      formatversion: 2,
     }).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;
Line 56: Line 46:
   var _selText    = '';
   var _selText    = '';
   var _selRect    = null;
   var _selRect    = null;
   var _notes      = [];       // was _comments, now local only
   var _notes      = [];
   var _bookmarks  = [];
   var _bookmarks  = [];
   var _activeTab  = 'notes';
   var _activeTab  = 'notes';
   var _selVersion = 0;
   var _selVersion = 0;
   var _fabSelVer  = -1;
   var _fabSelVer  = -1;
  var _mobile    = false;  // set on init


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


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


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
Line 95: Line 82:


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


     // ── Feedback composer (popup) ─────────────────────────────────────
     // ── Feedback composer (centered modal) ───────────────────────────
     $fbComposer = $( [
     $fbComposer = $( [
       '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
       '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
Line 137: Line 147:
       '  </select>',
       '  </select>',
       '  <div class="gra-fb-field-label">Details (optional)</div>',
       '  <div class="gra-fb-field-label">Details (optional)</div>',
       '  <textarea class="gra-composer-input" id="gra-fb-text"',
       '  <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>',
      '    placeholder="Describe the issue…" rows="3"></textarea>',
       '  <div class="gra-fb-field-label">Your email (optional)</div>',
       '  <div class="gra-fb-field-label">Your email (optional)</div>',
       '  <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email"',
       '  <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">',
      '    type="email" placeholder="you@example.com" autocomplete="email">',
       '  <div class="gra-composer-actions">',
       '  <div class="gra-composer-actions">',
       '    <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>',
       '    <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>',
Line 149: Line 157:
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $fbComposer );
     $('body').append($fbComposer);


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


     // ── Bookmark composer ─────────────────────────────────────────────
     // ── Bookmark composer ─────────────────────────────────────────────
Line 175: Line 182:
       '    Save bookmark',
       '    Save bookmark',
       '  </div>',
       '  </div>',
       '  <input class="gra-composer-input" id="gra-bm-input"',
       '  <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">',
      '    type="text" placeholder="Name this bookmark…" autocomplete="off">',
       '  <div class="gra-composer-actions">',
       '  <div class="gra-composer-actions">',
       '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
       '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
Line 183: Line 189:
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $bmComposer );
     $('body').append($bmComposer);


     // ── Right panel ───────────────────────────────────────────────────
     // ── Right panel ───────────────────────────────────────────────────
Line 202: Line 208:
       '  <div id="gra-panel-body">',
       '  <div id="gra-panel-body">',
       '    <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>',
       '    <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>',
       '    <div class="gra-pane"             id="gra-pane-bookmarks"></div>',
       '    <div class="gra-pane" id="gra-pane-bookmarks"></div>',
       '  </div>',
       '  </div>',
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $panel );
     $('body').append($panel);


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


    // Toggle button (bottom-right, persistent) — notes icon
     var $toggle = $( [
     var $toggle = $( [
       '<button id="gra-toggle" aria-label="Notes">',
       '<button id="gra-toggle" aria-label="Notes">',
Line 218: Line 223:
       '</button>',
       '</button>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $toggle );
     $('body').append($toggle);
     $toggle.on( 'click', function() {
     $toggle.on('click', function() {
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel( _activeTab );
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
     } );
     });


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


Line 247: Line 250:
   function captureSelection() {
   function captureSelection() {
     var sel = window.getSelection();
     var sel = window.getSelection();
     if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
     if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
     var range = sel.getRangeAt(0);
     var range = sel.getRangeAt(0);
     var text  = sel.toString().trim();
     var text  = sel.toString().trim();
     if ( !text || text.length < 2 ) return false;
     if (!text || text.length < 2) return false;
     var contentEl = document.querySelector( CONTENT_SEL );
     var contentEl = document.querySelector(CONTENT_SEL);
     if ( !contentEl ) return false;
     if (!contentEl) return false;
     var start = range.commonAncestorContainer;
     var start = range.commonAncestorContainer;
     if ( start.nodeType === 3 ) start = start.parentNode;
     if (start.nodeType === 3) start = start.parentNode;
     if ( !contentEl.contains( start ) ) return false;
     if (!contentEl.contains(start)) return false;
     _selText  = text;
     _selText  = text;
     _selRange = range.cloneRange();
     _selRange = range.cloneRange();
Line 262: Line 265:
   }
   }


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


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // FAB POSITIONING
   // DESKTOP FAB
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function showFab( rect ) {
   function showFab(rect) {
     if ( !rect ) return;
     if (!rect) return;
     var fabW = 46, fabH = 126;   // 3 buttons now
     var fabW = 46, fabH = 126;
     var top  = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
     var top  = rect.top + (rect.height/2) - (fabH/2);
     var left = rect.right + 10;
     var 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');
   }
   }


Line 292: Line 299:


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // COMPOSER POSITIONING
   // MOBILE BOTTOM BAR
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


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


Line 312: Line 340:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function wrapSelection( id, cssClass ) {
   function wrapSelection(id, cssClass) {
     if ( !_selRange ) return null;
     if (!_selRange) return null;
     var range = _selRange;
     var range = _selRange;
     _selRange = null;
     _selRange = null;
Line 320: Line 348:
       span.className = cssClass;
       span.className = cssClass;
       span.setAttribute('data-gra-id', id);
       span.setAttribute('data-gra-id', id);
       range.surroundContents( span );
       range.surroundContents(span);
       return span;
       return span;
     } catch (e) {
     } catch(e) {
       try {
       try {
         var frag = range.extractContents();
         var frag = range.extractContents();
Line 336: Line 364:


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // FEEDBACK FLOW (sends email, not stored on wiki)
   // FEEDBACK FLOW
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function openFeedbackComposer() {
   function openFeedbackComposer() {
     hideFab();
     hideActions();
    // Show selected text in the popup
     $fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : ''));
     $fbQuote.text( _selText.slice(0, 200) + (_selText.length > 200 ? '…' : '') );
     $fbIssueType.val('');
     $fbIssueType.val('');
     $fbText.val('');
     $fbText.val('');
    $fbEmail.val('');
     $fbSubmit.prop('disabled', true);
     $fbSubmit.prop('disabled', true);
     $( '#gra-fb-status' ).text('').removeClass('gra-fb-ok gra-fb-err');
     $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
     // Center on screen — not anchored to selection
     if (currentUserEmail) $fbEmail.val(currentUserEmail);
     $fbComposer.css({ top: '', left: '', transform: '' });
    else $fbEmail.val('');
    // Pre-fill email for logged-in users
     $fbComposer.css({top:'', left:'', transform:''});
    if ( currentUserEmail ) {
      $fbEmail.val( currentUserEmail );
    }
     $fbComposer.addClass('gra-composer-visible');
     $fbComposer.addClass('gra-composer-visible');
     $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
     setTimeout( function() { $fbIssueType.focus(); }, isMobile() ? 300 : 0 );
     setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0);
   }
   }


Line 370: Line 393:
     var email    = $fbEmail.val().trim();
     var email    = $fbEmail.val().trim();
     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:       'Wrong text',
       wrong_text: 'Formatting error', reference_issue: 'Reference issue',
      reference_issue: 'Reference issue',
       spelling_mistake: 'Spelling mistake', other: 'Other'
       spelling_mistake: 'Spelling mistake',
      other:           'Other'
     };
     };


    // POST to /feedback.php on the same server — no mailto, no redirect
     var payload = new FormData();
     var payload = new FormData();
     payload.append( 'issue_type',    issueLabels[issueType] || issueType );
     payload.append('issue_type',    issueLabels[issueType] || issueType);
     payload.append( 'page',          pageTitle.replace(/_/g,' ') );
     payload.append('page',          pageTitle.replace(/_/g,' '));
     payload.append( 'url',          window.location.href );
     payload.append('url',          window.location.href);
     payload.append( 'selected_text', quote );
     payload.append('selected_text', quote);
     payload.append( 'details',      details || '' );
     payload.append('details',      details || '');
     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(); })
       .then( function(data) {
       .then(function(data){
         if ( data && data.ok ) {
         if (data && data.ok) showFeedbackSuccess();
          showFeedbackSuccess();
         else showFeedbackError(data && data.error ? data.error : 'Could not send.');
         } else {
       })
          showFeedbackError( data && data.error ? data.error : 'Could not send.' );
       .catch(function(){ showFeedbackError('Network error. Please try again.'); });
        }
       } )
       .catch( function() {
        showFeedbackError( 'Network error. Please try again.' );
      } );
   }
   }


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


   function showFeedbackError( msg ) {
   function showFeedbackError(msg) {
     $fbSubmit.prop('disabled', false).text('Send');
     $fbSubmit.prop('disabled', false).text('Send');
     $( '#gra-fb-status' )
     $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
      .text('✗ ' + msg)
      .addClass('gra-fb-err');
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // NOTE FLOW (local only — localStorage, not wiki)
   // NOTE FLOW
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function openNoteComposer() {
   function openNoteComposer() {
     hideFab();
     hideActions();
     positionComposer( $ntComposer );
     positionComposer($ntComposer);
     $ntComposer.addClass('gra-composer-visible');
     $ntComposer.addClass('gra-composer-visible');
     setTimeout( function() { $ntInput.focus(); }, isMobile() ? 300 : 0 );
    if (isMobile()) $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
   }
   }


   function closeNoteComposer() {
   function closeNoteComposer() {
     $ntComposer.removeClass('gra-composer-visible');
     $ntComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
     $ntInput.val('');
     $ntInput.val('');
     $ntSubmit.prop('disabled', true);
     $ntSubmit.prop('disabled', true);
Line 442: Line 454:
   function submitNote() {
   function submitNote() {
     var text = $ntInput.val().trim();
     var text = $ntInput.val().trim();
     if ( !text ) return;
     if (!text) return;
     var id    = uid();
     var id    = uid();
     var ts    = nowIso();
     var ts    = nowIso();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var span  = wrapSelection( id, 'gra-note-highlight' );
     var span  = wrapSelection(id, 'gra-note-highlight');
     if ( span ) span.setAttribute('data-gra-quote', quote);
     if (span) span.setAttribute('data-gra-quote', quote);
     var entry = { id:id, ts:ts, quote:quote, text:text };
     _notes.push({id:id, ts:ts, quote:quote, text:text});
    _notes.push( entry );
     persistNotes();
     persistNotes();
     persistNoteHighlight( id, quote );
     persistNoteHighlight(id, quote);
     renderNoteCards();
     renderNoteCards();
     closeNoteComposer();
     closeNoteComposer();
Line 458: Line 469:


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


Line 473: Line 480:


   function openBookmarkComposer() {
   function openBookmarkComposer() {
     hideFab();
     hideActions();
     positionComposer( $bmComposer );
     positionComposer($bmComposer);
     $bmComposer.addClass('gra-composer-visible');
     $bmComposer.addClass('gra-composer-visible');
     setTimeout( function() { $bmInput.focus(); }, isMobile() ? 300 : 0 );
    if (isMobile()) $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
   }
   }


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


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


   function deleteBookmark( id ) {
   function deleteBookmark(id) {
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     if (span) {
     if (span) {
       var parent = span.parentNode;
       var p = span.parentNode;
       while (span.firstChild) parent.insertBefore(span.firstChild, span);
       while (span.firstChild) p.insertBefore(span.firstChild, span);
       parent.removeChild(span);
       p.removeChild(span);
     }
     }
     persistBookmarks(); renderBookmarkCards();
     persistBookmarks(); renderBookmarkCards();
Line 546: Line 555:
   function renderNoteCards() {
   function renderNoteCards() {
     if (!_notes.length) {
     if (!_notes.length) {
       $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and click ✎ to add one.</div>');
       $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>');
       return;
       return;
     }
     }
     var html = '';
     var html = '';
     _notes.slice().reverse().forEach(function(n){
     _notes.slice().reverse().forEach(function(n){
       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>'
             + '<div class="gra-avatar">✎</div>'
             + '<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>' : '')
             + '</div>'
             + '</div>'
             + '<button class="gra-note-del" data-del-id="' + esc(n.id) + '" title="Delete">×</button>'
             + '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
             + '</div>'
             + '</div>'
             + (n.quote ? '<div class="gra-card-quote">' + esc(n.quote) + '</div>' : '')
             + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
             + '<div class="gra-card-text">' + esc(n.text) + '</div>'
             + '<div class="gra-card-text">'+esc(n.text)+'</div>'
             + '</div>';
             + '</div>';
     });
     });
Line 566: Line 575:
   }
   }


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


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


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


     // ── Desktop: mouseup / keyup ──────────────────────────────────
     // ── Desktop mouseup ───────────────────────────────────────────
     $( document ).on('mouseup keyup', function(e){
     $(document).on('mouseup', function(e){
       if ( e.type === 'mouseup' && e.button !== 0 ) return;
       if (e.button !== 0) return;
       setTimeout( tryShowFab, 20 );
      if (isMobile()) return;  // mobile handled separately
       setTimeout(tryShowActions, 20);
     });
     });


     // ── Mobile: selectionchange ───────────────────────────────────
     // ── Mobile: selectionchange (debounced 600ms) ─────────────────
     var _selChangeTimer = null;
    // We wait longer than v3 so the browser's own copy menu has time
    // to appear first — user can still copy, THEN our bar slides up.
     var _selTimer = null;
     document.addEventListener('selectionchange', function() {
     document.addEventListener('selectionchange', function() {
       _selVersion++;
       _selVersion++;
       clearTimeout( _selChangeTimer );
       clearTimeout(_selTimer);
       var v = _selVersion;
       var v = _selVersion;
       _selChangeTimer = setTimeout(function(){
       _selTimer = setTimeout(function(){
         if ( v !== _selVersion ) return;
         if (v !== _selVersion) return;
         if ( _fabSelVer === v ) return;
         if (_fabSelVer === v) return;
         tryShowFab();
         if (!isMobile()) return;  // desktop uses mouseup
       }, 400);
        tryShowActions();
       }, 600);   // 600ms — after browser copy menu appears
     });
     });


     // ── Mobile: touchend fallback ─────────────────────────────────
     // ── Click outside → hide actions ──────────────────────────────
     document.addEventListener('touchend', function(e){
     $(document).on('mousedown touchstart', function(e){
       if ( $fab[0] && $fab[0].contains(e.target) ) return;
      var t = e.target;
       if ( $fbComposer[0] && $fbComposer[0].contains(e.target) ) return;
       if ($fab[0]         && $fab[0].contains(t))        return;
       if ( $ntComposer[0] && $ntComposer[0].contains(e.target) ) return;
      if ($mobileBar[0]  && $mobileBar[0].contains(t))   return;
       if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return;
       if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
       setTimeout( tryShowFab, 80 );
       if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
     }, { passive: true });
       if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
       hideActions();
     });


     // ── Click outside → hide FAB ──────────────────────────────────
     // ── Desktop FAB buttons ───────────────────────────────────────
     $( document ).on('mousedown touchstart', function(e){
     $('#gra-fab-feedback').on('click', function(e){
       var t = e.target;
       e.preventDefault(); e.stopPropagation();
       if ( $fab[0] && $fab[0].contains(t) ) return;
       if (!_selRange && !captureSelection()) return;
       if ( $fbComposer[0] && $fbComposer[0].contains(t) ) return;
      openFeedbackComposer();
       if ( $ntComposer[0] && $ntComposer[0].contains(t) ) return;
    });
       if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return;
    $('#gra-fab-note').on('click', function(e){
       hideFab();
      e.preventDefault(); e.stopPropagation();
       if (!_selRange && !captureSelection()) return;
       openNoteComposer();
    });
    $('#gra-fab-bookmark').on('click', function(e){
      e.preventDefault(); e.stopPropagation();
       if (!_selRange && !captureSelection()) return;
       openBookmarkComposer();
     });
     });


     // ── FAB buttons ───────────────────────────────────────────────
     // ── Mobile bottom bar buttons ─────────────────────────────────
     $( '#gra-fab-feedback' ).on('click touchend', function(e){
     $('#gra-mob-feedback').on('click touchend', function(e){
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if ( !_selRange && !captureSelection() ) return;
      hideMobileBar();
       if (!_selRange && !captureSelection()) return;
       openFeedbackComposer();
       openFeedbackComposer();
     });
     });
     $( '#gra-fab-note' ).on('click touchend', function(e){
     $('#gra-mob-note').on('click touchend', function(e){
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if ( !_selRange && !captureSelection() ) return;
      hideMobileBar();
       if (!_selRange && !captureSelection()) return;
       openNoteComposer();
       openNoteComposer();
     });
     });
     $( '#gra-fab-bookmark' ).on('click touchend', function(e){
     $('#gra-mob-bookmark').on('click touchend', function(e){
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if ( !_selRange && !captureSelection() ) return;
      hideMobileBar();
       if (!_selRange && !captureSelection()) return;
       openBookmarkComposer();
       openBookmarkComposer();
    });
    $('#gra-mob-dismiss').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      hideMobileBar();
      // Clear selection visually
      if (window.getSelection) window.getSelection().removeAllRanges();
     });
     });


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


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


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


     // ── Panel ─────────────────────────────────────────────────────
     // ── Panel ─────────────────────────────────────────────────────
     $( '#gra-panel-close' ).on('click', closePanel);
     $('#gra-panel-close').on('click', closePanel);
     $backdrop.on('click touchstart', function() {
     $backdrop.on('click touchend', function(e){
       if ( $fbComposer.hasClass('gra-composer-visible') ) closeFeedbackComposer();
      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();
       else closePanel();
     });
     });
Line 716: Line 748:
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });


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


    // Click note highlight in text → open panel
     $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
     $( CONTENT_SEL ).on('click', '.gra-note-highlight', function(){
       var id = $(this).attr('data-gra-id');
       var id = $( this ).attr('data-gra-id');
       openPanel('notes');
       openPanel('notes');
       setTimeout(function(){
       setTimeout(function(){
Line 754: Line 781:
       }, 100);
       }, 100);
     });
     });
     $( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){
     $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
       var id = $( this ).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       openPanel('bookmarks');
       openPanel('bookmarks');
       setTimeout(function(){
       setTimeout(function(){
Line 763: Line 790:
     });
     });


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


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // RESTORE HIGHLIGHTS on page reload
   // RESTORE HIGHLIGHTS
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


Line 846: Line 872:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   $( function () {
   $(function() {
    // Detect mobile once on load
    _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    window.addEventListener('resize', function(){
      _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    });
 
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();