Jump to content

MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

From Anandamakaranda
No edit summary
No edit summary
 
(10 intermediate revisions by the same user not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Comments + Bookmarks
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v4)
  * ══════════════════════════════════════════════════════════════════════
  * ══════════════════════════════════════════════════════════════════════
  *
  *
  * BEHAVIOUR (mirrors Google Docs)
  * CHANGES FROM v3
  * ────────────────────────────────
  * ────────────────
  * 1. User selects text anywhere in mw-content-text.
  * • Mobile: FAB no longer fights browser's native copy/paste menu.
* 2. A floating action strip (FAB) appears to the right of the selection
  *   On mobile, a bottom-sheet action bar slides up after selection
*    with two icon buttons: Comment and Bookmark.
  *   instead of a tiny floating strip next to the text.
  * 3. Clicking Comment:
  * • Mobile: Long-press detection improved — waits for selectionchange
*    - Opens an inline composer card anchored to the selection position.
  *   to settle before showing the action bar.
*    - On submit: wraps the selected text in a yellow highlight span,
  * • Mobile: Action bar buttons are large (48px tap targets) with labels.
*      saves the comment to <PageTitle>/Comments via MW edit API,
  * • Desktop: FAB strip unchanged — appears beside selection.
*      posts a notification to the admin user talk page,
  * • Feedback composer: centered modal on all screen sizes.
  *     and adds a card to the Comments tab of the right panel.
* 4. Clicking Bookmark:
*    - Opens a small composer to name the bookmark.
*    - On submit: wraps selected text in a blue highlight span,
*      saves bookmark in localStorage (per-user, per-page),
*      and adds a card to the Bookmarks tab.
  *   - Clicking a bookmark card scrolls to and flashes that highlight.
* 5. Right panel:
*    - Slides in from the right as an overlay (no layout shift).
  *   - Tab 1: Comments  (loads from /Comments wiki page)
*    - Tab 2: Bookmarks (loads from localStorage)
*    - Panel can be closed via ✕ or clicking the backdrop.
* 6. Clicking any comment/bookmark highlight in the text:
*    - Opens the panel to the correct tab and scrolls to the card.
*
* STORAGE
* ───────
* Comments  → MediaWiki page: Talk:<PageTitle>/GrComments
*            Talk namespace is excluded from XML/PDF exports and never
*            shown in the content editor — comments cannot affect exports.
*            Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}}
* Bookmarks → localStorage (per-user, per-page — not shared across users)
*            Key: grantha_bm_<pageName>
*
* ADMIN NOTIFICATION
* ──────────────────
* On new comment: sends email via MW EmailUser API (action=emailuser).
  * Email contains the comment text and a direct anchor link to the passage.
* Fallback: if admin has no email set, posts to User_talk:ADMIN_USER instead.
* Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults).
*
* DEPENDENCIES
* ────────────
  * • jQuery (loaded by MediaWiki)
* • mw.Api  (MediaWiki core)
  * • gr_annotations.css  (companion stylesheet)
* • /images/commentary.svg, /images/bookmark.svg  (Hostinger)
*
* DEPLOY
* ──────
* Add both files to MediaWiki:Common.css / Common.js, or register as
* a gadget in MediaWiki:Gadgets-definition:
*  GrAnnotations[ResourceLoader|default]|gr_annotations.js|gr_annotations.css
* ══════════════════════════════════════════════════════════════════════
  */
  */


Line 64: Line 20:


   // ── Configuration ────────────────────────────────────────────────────
   // ── Configuration ────────────────────────────────────────────────────
   var ADMIN_USER  = 'GranthaGate';
   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' ) ) || '' );
   var NT_LS_KEY    = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var CMT_LS_KEY  = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var pageTitle     = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
   var pageTitle   = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
   var currentUser   = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
  /* Comments stored on Talk:<PageTitle>/GrComments — talk namespace is
   var userInitial   = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
  * excluded from XML/PDF exports and never shown in the content editor,
  var currentUserEmail = '';
  * so comments cannot tamper with document content or affect exports. */
 
   var commentsPage = 'Talk:' + pageTitle + '/GrComments';
  if ( currentUser && window.mw ) {
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
    new mw.Api().get({
   var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
      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 () {} );
  }


  // Only run on content namespaces, not on the /Comments page itself
   if ( window.mw ) {
   if ( window.mw ) {
     var ns = mw.config.get( 'wgNamespaceNumber' );
     var ns = mw.config.get( 'wgNamespaceNumber' );
     if ( ns < 0 ) return;
     if ( ns < 0 ) return;
    if ( /\/Comments$/.test( pageTitle ) ) return;
   }
   }


   // ── State ────────────────────────────────────────────────────────────
   // ── State ────────────────────────────────────────────────────────────
  var _sel        = null;  // current Selection snapshot
   var _selRange  = null;
   var _selRange  = null;   // saved Range for wrapping
   var _selText    = '';
   var _selText    = '';     // selected text string
   var _selRect    = null;
   var _selRect    = null;   // bounding rect of selection
   var _notes      = [];
   var _comments  = [];     // [{id, anchor, author, ts, quote, text}]
   var _bookmarks  = [];
   var _bookmarks  = [];     // [{id, name, quote, anchorHtml, ts}]
   var _activeTab = 'notes';
   var _cmtLoaded = false;
   var _selVersion = 0;
   var _activeTab = 'comments'; // 'comments' | 'bookmarks'
  var _fabSelVer = -1;
  var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;   // set immediately


   // ── 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;')
                        .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
   }
   }
  function esc( s ) {
   function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); }
    return String( s || '' )
   function fmtTs(ts) {
      .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 {
     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( val, min, max ) { return Math.max(min, Math.min(max, val)); }
   function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); }
  function isMobile() { return _mobile; }


   // ── DOM references (populated in boot) ───────────────────────────────
   // ── DOM references ───────────────────────────────────────────────────
   var $fab, $panel, $backdrop, $panelBody;
   var $fab, $mobileBar, $panel, $backdrop;
   var $cmpComposer, $cmpInput, $cmpSubmit;
   var $ntComposer, $ntInput, $ntSubmit;
   var $bmComposer, $bmInput, $bmSubmit;
   var $bmComposer, $bmInput, $bmSubmit;
   var $tabComments, $tabBookmarks;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $paneComments, $paneBookmarks;
   var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
Line 126: Line 82:


   function buildDom() {
   function buildDom() {
 
     // ── Desktop FAB strip ────────────────────────────────────────────
     // ── Floating action strip ────────────────────────────────────────
     $fab = $( [
     $fab = $( [
       '<div id="gra-fab">',
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
       '  <button class="gra-fab-btn" id="gra-fab-comment" type="button">',
       '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">',
       '    <span class="gra-icon gra-icon-comment"></span>',
       '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Add comment</span>',
       '    <span class="gra-fab-tooltip">Feedback</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button">',
      '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Add note">',
       '    <span class="gra-icon gra-icon-bookmark"></span>',
      '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
      '    <span class="gra-fab-tooltip">Note</span>',
      '  </button>',
       '  <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-fab-tooltip">Bookmark</span>',
       '    <span class="gra-fab-tooltip">Bookmark</span>',
       '  </button>',
       '  </button>',
       '</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 (centered modal) ───────────────────────────
    $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);


     // ── Comment composer card ────────────────────────────────────────
     // ── Note composer ────────────────────────────────────────────────
     $cmpComposer = $( [
     $ntComposer = $( [
       '<div class="gra-composer" id="gra-cmp-composer">',
       '<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-cmp-avatar">' + esc(userInitial) + '</div>',
       '    <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
       '    <div class="gra-composer-name" id="gra-cmp-name">' + esc(currentUser || 'Anonymous') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Note') + '</div>',
       '  </div>',
       '  </div>',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
       '  <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>',
      '    placeholder="Comment or add others with @…" rows="3"></textarea>',
       '  <div class="gra-composer-actions">',
       '  <div class="gra-composer-actions">',
       '    <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>',
       '    <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>',
       '    <button class="gra-btn-submit" id="gra-cmp-submit" disabled>Comment</button>',
       '    <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>',
       '  </div>',
       '  </div>',
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $cmpComposer );
     $('body').append($ntComposer);


     // ── Bookmark composer card ────────────────────────────────────────
     // ── Bookmark composer ─────────────────────────────────────────────
     $bmComposer = $( [
     $bmComposer = $( [
       '<div class="gra-bm-composer" id="gra-bm-composer">',
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
       '  <div class="gra-bm-composer-label">',
       '  <div class="gra-bm-composer-label">',
       '    <span class="gra-icon gra-icon-bookmark"></span>',
       '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
       '    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="Bookmark name…" 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 174: Line 189:
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $bmComposer );
     $('body').append($bmComposer);


     // ── Right panel ───────────────────────────────────────────────────
     // ── Right panel ───────────────────────────────────────────────────
     $panel = $( [
     $panel = $( [
       '<div id="gra-panel">',
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
       '  <div id="gra-panel-head">',
       '  <div id="gra-panel-head">',
       '    <div></div>',
       '    <div id="gra-panel-title"></div>',
       '    <button id="gra-panel-close" title="Close panel">✕</button>',
       '    <button id="gra-panel-close" title="Close">✕</button>',
       '  </div>',
       '  </div>',
       '  <div id="gra-tabs">',
       '  <div id="gra-tabs">',
       '    <button class="gra-tab gra-tab-active" id="gra-tab-comments">',
       '    <button class="gra-tab gra-tab-active" id="gra-tab-notes">',
       '      <span class="gra-icon gra-icon-comment"></span> Comments',
       '      <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes',
       '    </button>',
       '    </button>',
       '    <button class="gra-tab" id="gra-tab-bookmarks">',
       '    <button class="gra-tab" id="gra-tab-bookmarks">',
       '      <span class="gra-icon gra-icon-bookmark"></span> Bookmarks',
       '      <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks',
       '    </button>',
       '    </button>',
       '  </div>',
       '  </div>',
       '  <div id="gra-panel-body">',
       '  <div id="gra-panel-body">',
       '    <div class="gra-pane gra-pane-active" id="gra-pane-comments"></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
     $backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>');
     $backdrop = $('<div id="gra-backdrop"></div>');
     $('body').append($backdrop);
     $( 'body' ).append( $backdrop );


    // Persistent toggle button — always visible, opens the panel
     var $toggle = $( [
     var $toggle = $( [
       '<button id="gra-toggle" title="Comments &amp; Bookmarks">',
       '<button id="gra-toggle" aria-label="Notes">',
       '  <span class="gra-icon gra-icon-comment" id="gra-toggle-icon"></span>',
       '  <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>',
       '  <span id="gra-toggle-badge"></span>',
       '  <span id="gra-toggle-badge" aria-live="polite"></span>',
       '</button>',
       '</button>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $toggle );
     $('body').append($toggle);
     $toggle.on( 'click', function() {
     $toggle.on('click', function() {
       if ( $panel.hasClass('gra-panel-open') ) {
       $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
        closePanel();
    });
       } else {
 
        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');
  }


    // Cache references
  // ════════════════════════════════════════════════════════════════════
    $panelBody    = $( '#gra-panel-body' );
  // SELECTION
    // Set panel title to page name
  // ════════════════════════════════════════════════════════════════════
    $( '#gra-panel-head div' ).first()
      .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) )
      .css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' });
    $tabComments  = $( '#gra-tab-comments' );
    $tabBookmarks  = $( '#gra-tab-bookmarks' );
    $paneComments  = $( '#gra-pane-comments' );
    $paneBookmarks = $( '#gra-pane-bookmarks' );
    $cmpInput      = $( '#gra-cmp-input' );
    $cmpSubmit    = $( '#gra-cmp-submit' );
    $bmInput      = $( '#gra-bm-input' );
    $bmSubmit      = $( '#gra-bm-submit' );


     // Disable comment submit when not logged in
  function captureSelection() {
     if ( !currentUser ) {
    var sel = window.getSelection();
      $cmpSubmit.prop( 'disabled', true );
    if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
      $cmpComposer.find( '.gra-composer-actions' )
    var range = sel.getRangeAt(0);
        .prepend( '<a href="' + esc( mw ? mw.util.getUrl('Special:UserLogin') : '/wiki/Special:UserLogin' ) +
    var text  = sel.toString().trim();
                  '" style="font-size:11px;color:#999;margin-right:auto">Log in to comment</a>' );
    if (!text || text.length < 2) return false;
     var contentEl = document.querySelector(CONTENT_SEL);
     if (!contentEl) return false;
    var start = range.commonAncestorContainer;
    if (start.nodeType === 3) start = start.parentNode;
    if (!contentEl.contains(start)) return false;
    _selText  = text;
    _selRange = range.cloneRange();
    _selRect  = range.getBoundingClientRect();
    return true;
  }
 
  function tryShowActions() {
    if ($fbComposer.hasClass('gra-composer-visible')) return;
    if ($ntComposer.hasClass('gra-composer-visible')) return;
    if ($bmComposer.hasClass('gra-composer-visible')) return;
    if (!captureSelection()) {
      hideActions();
      return;
    }
    _fabSelVer = _selVersion;
    if (_mobile) {
      showMobileBar();
    } else {
      showFab(_selRect);
     }
     }
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // FLOATING ACTION STRIP — position & show/hide
   // DESKTOP FAB
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function showFab( rect ) {
   function showFab(rect) {
     if ( !rect ) return;
     if (_mobile) return;   // mobile uses bottom bar
    // FAB uses position:fixed — coords are viewport-relative (no scroll offset needed).
    if (!rect) return;
    // Place strip to the right of the selection; if it would go off-screen, place to the left.
     var fabW = 46, fabH = 126;
     var fabW = 46; var fabH = 84;
     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 too close to right edge, flip to left of selection
     top = clamp(top, 8, window.innerHeight - fabH - 8);
     if ( left + fabW > window.innerWidth - 8 ) {
     left = clamp(left, 8, window.innerWidth - fabW - 8);
      left = rect.left - fabW - 10;
     $fab.css({top: top+'px', left: left+'px'}).addClass('gra-fab-visible');
    }
    // Clamp vertically within viewport
     top = clamp( top, 8, window.innerHeight - fabH - 8 );
    // Clamp horizontally
     left = clamp( left, 8, window.innerWidth - fabW - 8 );
 
     $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
   }
   }


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


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // SELECTION HANDLING
   // MOBILE BOTTOM BAR
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function captureSelection() {
   function showMobileBar() {
     var sel = window.getSelection();
     $mobileBar.addClass('gra-mobile-bar-visible');
    if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
  }


    var range    = sel.getRangeAt(0);
  function hideMobileBar() {
     var text    = sel.toString().trim();
     $mobileBar.removeClass('gra-mobile-bar-visible');
    if ( !text || text.length < 2 ) return false;
  }


    // Must be inside mw-content-text
  function hideActions() {
    var contentEl = document.querySelector( CONTENT_SEL );
     hideFab();
     if ( !contentEl ) return false;
     hideMobileBar();
     var startEl = range.commonAncestorContainer;
  }
    if ( startEl.nodeType === 3 ) startEl = startEl.parentNode;
    if ( !contentEl.contains( startEl ) ) return false;


    _selText  = text;
  // Debug helper — remove after testing
     _selRange = range.cloneRange();
  window._graDebug = function() {
     _selRect  = range.getBoundingClientRect();
     console.log('mobile:', _mobile, 'selText:', _selText, 'selRange:', _selRange);
    return true;
     console.log('bar visible:', document.getElementById('gra-mobile-bar') &&
   }
      document.getElementById('gra-mobile-bar').classList.contains('gra-mobile-bar-visible'));
   };


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // COMPOSER POSITIONING
   // COMPOSER POSITIONING (desktop note/bookmark only)
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function positionComposer( $el ) {
   function positionComposer($el) {
     if ( !_selRect ) return;
    if (isMobile()) {
     // position:fixed — viewport coords only
      // On mobile always center — don't anchor to selection
      $el.css({top: '', left: '', transform: ''});
      return;
    }
     if (!_selRect) return;
     var W  = 340;
     var top  = _selRect.bottom + 8;
     var top  = _selRect.bottom + 8;
     var left = _selRect.left;
     var left = _selRect.left;
    // Keep composer within viewport
     if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
    var composerW = 308;
     left = Math.max(left, 8);
     if ( left + composerW > window.innerWidth - 8 ) {
     if (top + 280 > window.innerHeight) top = _selRect.top - 290;
      left = window.innerWidth - composerW - 8;
     top = Math.max(top, 8);
    }
     $el.css({top: top+'px', left: left+'px'});
     left = Math.max( left, 8 );
    // If composer would appear below viewport, show above selection instead
     if ( top + 160 > window.innerHeight ) {
      top = _selRect.top - 170;
    }
     top = Math.max( top, 8 );
     $el.css({ top: top + 'px', left: left + 'px' });
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // WRAP SELECTION IN HIGHLIGHT SPAN
   // WRAP SELECTION
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function wrapSelection( id, cssClass ) {
   function wrapSelection(id, cssClass) {
     if ( !_selRange ) return null;
     if (!_selRange) return null;
    var range = _selRange;
    _selRange = null;
     try {
     try {
       var span = document.createElement('span');
       var span = document.createElement('span');
       span.className = cssClass;
       span.className = cssClass;
       span.setAttribute('data-gra-id', id);
       span.setAttribute('data-gra-id', id);
       _selRange.surroundContents( span );
       range.surroundContents(span);
       return span;
       return span;
     } catch ( e ) {
     } catch(e) {
      // surroundContents fails for multi-element selections;
      // extract and wrap the fragment
       try {
       try {
         var frag = _selRange.extractContents();
         var frag = range.extractContents();
         var span2 = document.createElement('span');
         var sp2  = document.createElement('span');
         span2.className = cssClass;
         sp2.className = cssClass;
         span2.setAttribute('data-gra-id', id);
         sp2.setAttribute('data-gra-id', id);
         span2.appendChild( frag );
         sp2.appendChild(frag);
         _selRange.insertNode( span2 );
         range.insertNode(sp2);
         return span2;
         return sp2;
       } catch(e2) { return null; }
       } catch(e2) { return null; }
     }
     }
Line 347: Line 372:


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // COMMENT FLOW
   // FEEDBACK FLOW
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function openCommentComposer() {
   function openFeedbackComposer() {
     hideFab();
     hideActions();
     // Keep selection alive — blur the textarea briefly then refocus
     $fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : ''));
     positionComposer( $cmpComposer );
     $fbIssueType.val('');
     $cmpComposer.addClass('gra-composer-visible');
    $fbText.val('');
     $cmpInput.val('').focus();
     $fbSubmit.prop('disabled', true);
    $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
    if (currentUserEmail) $fbEmail.val(currentUserEmail);
    else $fbEmail.val('');
    $fbComposer.css({top:'', left:'', transform:''});
    $fbComposer.addClass('gra-composer-visible');
     $backdrop.addClass('gra-backdrop-visible');
    setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0);
   }
   }


   function closeCommentComposer() {
   function closeFeedbackComposer() {
     $cmpComposer.removeClass('gra-composer-visible');
     $fbComposer.removeClass('gra-composer-visible');
     $cmpInput.val('');
     $backdrop.removeClass('gra-backdrop-visible');
    $cmpSubmit.prop('disabled', true);
     _selRange = null; _selText = ''; _selRect = null;
     _selRange = null;
    _selText = '';
    _selRect = null;
   }
   }


   function submitComment() {
   function submitFeedback() {
     var text  = $cmpInput.val().trim();
     var issueType = $fbIssueType.val();
    if ( !text || !currentUser ) return;
    var details  = $fbText.val().trim();
     var id    = uid();
     var email    = $fbEmail.val().trim();
    var ts    = nowIso();
     var quote     = $fbQuote.text();
     var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' );
    if (!issueType) return;


     // Wrap text in highlight span
     $fbSubmit.prop('disabled', true).text('Sending…');
     var span = wrapSelection( id, 'gra-comment-highlight' );
     $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
    if ( span ) span.setAttribute('data-gra-quote', quote);


    // Save locally
     var issueLabels = {
     var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text };
      wrong_text: 'Formatting error', reference_issue: 'Reference issue',
     _comments.push( entry );
      spelling_mistake: 'Spelling mistake', other: 'Other'
    // Persist highlight anchor so it survives page refresh
     };
    persistCommentHighlight( id, quote );


     // Persist to wiki
     var payload = new FormData();
     saveCommentToWiki( id, quote, text, ts );
    payload.append('issue_type',    issueLabels[issueType] || issueType);
     notifyAdmin( _activeId || '', quote, text, ts );
    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');


     // Update panel
     fetch('/feedback.php', {method:'POST', body:payload})
    renderCommentCards();
      .then(function(r){ return r.json(); })
    closeCommentComposer();
      .then(function(data){
    openPanel('comments');
        if (data && data.ok) showFeedbackSuccess();
        else showFeedbackError(data && data.error ? data.error : 'Could not send.');
      })
      .catch(function(){ showFeedbackError('Network error. Please try again.'); });
   }
   }


   function saveCommentToWiki( id, quote, text, ts ) {
   function showFeedbackSuccess() {
     if ( !window.mw ) return;
     $fbSubmit.prop('disabled', false).text('Send');
    var api = new mw.Api();
     $('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok');
    api.get({
     setTimeout(closeFeedbackComposer, 2500);
      action:'query', prop:'revisions', titles:commentsPage,
      rvprop:'content', rvslots:'main', formatversion:2,
    }).then(function(data){
      var page = (data.query.pages||[])[0]||{};
      var existing = '';
      if (page.revisions && page.revisions[0]) {
        var rev = page.revisions[0];
        existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
      }
      var entry = '{{GrComment\n'
                + '| id        = ' + id          + '\n'
                + '| author    = ' + currentUser + '\n'
                + '| timestamp = ' + ts          + '\n'
                + '| quote     = ' + quote.replace(/\n/g,' ') + '\n'
                + '| text     = ' + text.replace(/\n/g,' ')  + '\n'
                + '}}\n';
      var updated = (existing.trim() ? existing.trim() + '\n\n' : '') + entry;
      api.postWithEditToken({
        action:'edit', title:commentsPage, text:updated,
        summary:'New comment by ' + currentUser + ' on [[' + pageTitle + ']] (stored off-page)',
        bot:0,
      }).catch(function(){});
     }).catch(function(){});
   }
   }


   function notifyAdmin( anchorId, quote, commentText, ts ) {
   function showFeedbackError(msg) {
     /* Send a real email to the admin via MW's built-in EmailUser API.
     $fbSubmit.prop('disabled', false).text('Send');
    * This requires:
    $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
    *  1. The admin account (ADMIN_USER) has a confirmed email address set
  }
    *      in Special:Preferences.
    *  2. $wgEnableEmail and $wgEnableUserEmail are true in LocalSettings.php
    *      (both are true by default in MW).
    * The email contains a direct scrollable anchor link to the passage. */
    if ( !window.mw || !ADMIN_USER ) return;


    /* Build a deep-link URL with the anchor ID so the admin can jump
  // ════════════════════════════════════════════════════════════════════
    * directly to the highlighted passage with one click. */
  // NOTE FLOW
    var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' )
  // ════════════════════════════════════════════════════════════════════
                        .replace( '$1', pageTitle );
    var anchorLink  = window.location.origin + articlePath
                    + ( anchorId ? '#' + anchorId : '' );


     var pageDisplay = pageTitle.replace( /_/g, ' ' );
  function openNoteComposer() {
     var subject    = '[Grantha] New comment on "' + pageDisplay + '"';
     hideActions();
     var body        = 'A new comment has been posted on ' + pageDisplay + '.
    // Centered modal on all devices
    $ntComposer.css({ top: '', left: '', transform: '' });
     $ntComposer.addClass('gra-composer-visible');
    $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
  }


'
  function closeNoteComposer() {
                    + 'Posted by : ' + ( currentUser || 'Anonymous' ) + '
    $ntComposer.removeClass('gra-composer-visible');
'
    $backdrop.removeClass('gra-backdrop-visible');
                    + 'Time      : ' + ts + '
    $ntInput.val('');
'
    $ntSubmit.prop('disabled', true);
                    + 'Passage   : "' + quote + '"
    _selRange = null; _selText = ''; _selRect = null;
   }


'
   function submitNote() {
                    + 'Comment   :
    var text = $ntInput.val().trim();
' + commentText + '
     if (!text) return;
 
    var id    = uid();
'
    var ts    = nowIso();
                    + '──────────────────────────────────
    var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
'
    var span  = wrapSelection(id, 'gra-note-highlight');
                    + 'Jump to passage:
    if (span) span.setAttribute('data-gra-quote', quote);
' + anchorLink + '
    _notes.push({id:id, ts:ts, quote:quote, text:text});
 
    persistNotes();
'
    persistNoteHighlight(id, quote);
                    + 'View all comments:
    renderNoteCards();
'
    closeNoteComposer();
                    + window.location.origin
     openPanel('notes');
                    + ( mw.config.get('wgArticlePath') || '/wiki/$1' )
                        .replace( '$1', 'Talk:' + pageTitle + '/GrComments' )
                    + '
';
 
    new mw.Api().post({
      action:  'emailuser',
      target:  ADMIN_USER,
      subject:  subject,
      text:    body,
      token:    mw.user.tokens.get( 'csrfToken' ),
     }).catch(function(){
      /* EmailUser failed (e.g. admin has no email set) — fall back to
      * a talk-page notification so the admin still gets an Echo alert. */
      if ( !currentUser ) return;
      var adminTalk = 'User_talk:' + ADMIN_USER;
      var wikimsg = '== New comment on [[' + pageDisplay + ']] ==
'
                  + '; By: ' + ( currentUser || 'Anonymous' ) + '
'
                  + '; Passage: //' + quote + '//
'
                  + '; Link: ' + anchorLink + '
 
'
                  + commentText.slice(0,500)
                  + ( commentText.length > 500 ? '
…' : '' )
                  + '
 
[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 10:47, 24 April 2026 (UTC)';
      new mw.Api().postWithEditToken({
        action:'edit', title:adminTalk, section:'new',
        sectiontitle:'Comment on ' + pageDisplay,
        text:wikimsg,
        summary:'Comment notification from [[' + pageDisplay + ']]',
        bot:0,
      }).catch(function(){});
     });
   }
   }


  // ── Load comments from /Comments wiki page ───────────────────────
   function persistNotes() {
   function loadComments( cb ) {
     try { localStorage.setItem(NT_LS_KEY, JSON.stringify(_notes)); } catch(e){}
    if ( _cmtLoaded ) { if (cb) cb(); return; }
     if ( !window.mw ) { _cmtLoaded=true; if(cb)cb(); return; }
    new mw.Api().get({
      action:'query', prop:'revisions', titles:commentsPage,
      rvprop:'content', rvslots:'main', formatversion:2,
    }).then(function(data){
      var page = (data.query.pages||[])[0]||{};
      var wt = '';
      if (page.revisions && page.revisions[0]) {
        var rev = page.revisions[0];
        wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
      }
      _comments = parseCommentsWt(wt);
      _cmtLoaded = true;
      if (cb) cb();
    }).catch(function(){ _cmtLoaded=true; if(cb)cb(); });
   }
   }
 
   function loadNotes() {
   function parseCommentsWt( wt ) {
     try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
     var out = [];
    var re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g;
    var m;
    while ((m = re.exec(wt)) !== null) {
      var block = m[1];
      var f = {};
      var lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g;
      var lm;
      while ((lm = lr.exec(block)) !== null) {
        f[lm[1].trim()] = lm[2].trim();
      }
      if (f.id) out.push({
        id:f.id, author:f.author||'Anonymous',
        ts:f.timestamp||'', quote:f.quote||'', text:f.text||''
      });
    }
    return out;
   }
   }


Line 551: Line 489:


   function openBookmarkComposer() {
   function openBookmarkComposer() {
     hideFab();
     hideActions();
     positionComposer( $bmComposer );
     // Centered modal — same as feedback, no position anchoring
    $bmComposer.css({ top: '', left: '', transform: '' });
     $bmComposer.addClass('gra-composer-visible');
     $bmComposer.addClass('gra-composer-visible');
     $bmInput.val('').focus();
     $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;
     _selRange = null; _selText = ''; _selRect = null;
    _selText = '';
    _selRect = null;
   }
   }


   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 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-bookmark-highlight');
 
     if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
    // Wrap text in bookmark highlight span
     _bookmarks.push({id:id, name:name, quote:quote, ts:nowIso()});
     var span = wrapSelection( id, 'gra-bookmark-highlight' );
     if ( span ) {
      span.setAttribute('data-gra-id', id);
      span.setAttribute('data-gra-name', name);
    }
 
     var entry = { id:id, name:name, quote:quote, ts:ts };
    _bookmarks.push( entry );
     persistBookmarks();
     persistBookmarks();
     renderBookmarkCards();
     renderBookmarkCards();
Line 586: Line 517:
   }
   }


   function deleteBookmark( id ) {
   function deleteBookmark(id) {
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
    // Remove highlight span, replace with its text content
     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();
     persistBookmarks(); renderBookmarkCards();
    renderBookmarkCards();
   }
   }


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


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // PANEL — open / close / tabs
   // PANEL
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


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


Line 643: Line 563:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function renderCommentCards() {
   function renderNoteCards() {
     if ( _comments.length === 0 ) {
     if (!_notes.length) {
       $paneComments.html('<div class="gra-empty-state">No comments 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 = '';
     _comments.slice().reverse().forEach(function(c){
     _notes.slice().reverse().forEach(function(n){
       html += '<div class="gra-comment-card" data-gra-id="' + esc(c.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">' + esc((c.author||'?').charAt(0).toUpperCase()) + '</div>'
             + '<div class="gra-avatar"></div>'
             + '<div class="gra-card-meta">'
             + '<div class="gra-card-meta">'
            + '<div class="gra-card-author">' + esc(c.author) + '</div>'
             + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
             + (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
             + '</div>'
             + '</div>'
            + '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
             + '</div>'
             + '</div>'
             + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
             + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
             + '<div class="gra-card-text">' + esc(c.text) + '</div>'
             + '<div class="gra-card-text">'+esc(n.text)+'</div>'
             + '</div>';
             + '</div>';
     });
     });
     $paneComments.html(html);
     $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) {
      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() {
   function renderBookmarkCards() {
     if ( _bookmarks.length === 0 ) {
     if (!_bookmarks.length) {
       $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save a bookmark.</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"></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 bookmark">×</button>'
             + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>'
             + '</div>';
             + '</div>';
     });
     });
Line 688: Line 624:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   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 702: Line 638:
   function wireEvents() {
   function wireEvents() {


     // ── Selection → show FAB ──────────────────────────────────────
     // ── Desktop mouseup ───────────────────────────────────────────
     $( document ).on('mouseup keyup', function(e){
     $(document).on('mouseup', function(e){
       // Small delay so selection is committed
       if (e.button !== 0) return;
       setTimeout(function(){
      if (_mobile) return;  // mobile uses selectionchange
        if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
       setTimeout(tryShowActions, 20);
        if ( $bmComposer.hasClass('gra-composer-visible') ) return;
    });
        if ( captureSelection() ) {
 
          showFab( _selRect );
    // ── Mobile: selectionchange (debounced 600ms) ─────────────────
         } else {
    // We wait longer than v3 so the browser's own copy menu has time
          hideFab();
    // to appear first — user can still copy, THEN our bar slides up.
         }
    var _selTimer = null;
       }, 20);
    document.addEventListener('selectionchange', function() {
      _selVersion++;
      clearTimeout(_selTimer);
      var v = _selVersion;
      _selTimer = setTimeout(function(){
        if (v !== _selVersion) return;
         if (_fabSelVer === v) return;
         tryShowActions();
       }, 600);   // 600ms — after browser copy menu appears
     });
     });


     // ── Click outside → hide FAB ──────────────────────────────────
     // ── Click outside → hide actions ──────────────────────────────
     $( document ).on('mousedown', function(e){
     $(document).on('mousedown touchstart', function(e){
       var $t = $(e.target);
       var t = e.target;
       if ( !$t.closest('#gra-fab').length &&
      if ($fab[0]        && $fab[0].contains(t))        return;
          !$t.closest('.gra-composer').length &&
       if ($mobileBar[0]  && $mobileBar[0].contains(t))   return;
          !$t.closest('.gra-bm-composer').length ) {
      if ($fbComposer[0]  && $fbComposer[0].contains(t)) return;
        hideFab();
      if ($ntComposer[0]  && $ntComposer[0].contains(t)) return;
       }
      if ($bmComposer[0]  && $bmComposer[0].contains(t))  return;
       hideActions();
     });
     });


     // ── FAB: Comment ──────────────────────────────────────────────
     // ── Desktop FAB buttons ───────────────────────────────────────
     $( '#gra-fab-comment' ).on('click', function(e){
     $('#gra-fab-feedback').on('click', function(e){
       e.preventDefault();
       e.preventDefault(); e.stopPropagation();
       e.stopPropagation();
      if (!_selRange && !captureSelection()) return;
       if ( !captureSelection() ) return;
       openFeedbackComposer();
       openCommentComposer();
    });
    $('#gra-fab-note').on('click', function(e){
      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: Bookmark ─────────────────────────────────────────────
     // ── Mobile bottom bar buttons ─────────────────────────────────
     $( '#gra-fab-bookmark' ).on('click', function(e){
     $('#gra-mob-feedback').on('click touchend', function(e){
       e.preventDefault();
       e.preventDefault(); e.stopPropagation();
       e.stopPropagation();
      hideMobileBar();
       if ( !captureSelection() ) return;
      if (!_selRange && !captureSelection()) return;
      openFeedbackComposer();
    });
    $('#gra-mob-note').on('click touchend', function(e){
       e.preventDefault(); e.stopPropagation();
      hideMobileBar();
      if (!_selRange && !captureSelection()) return;
      openNoteComposer();
    });
    $('#gra-mob-bookmark').on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      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();
     });
     });


     // ── Comment composer ──────────────────────────────────────────
     // ── Feedback composer ─────────────────────────────────────────
     $cmpInput.on('input', function(){
     $fbIssueType.on('change', function(){
       $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);
       $fbSubmit.prop('disabled', !$(this).val());
     });
     });
     $( '#gra-cmp-cancel' ).on('click', function(){
     $('#gra-fb-cancel, #gra-fb-close').on('click', function(){
       closeCommentComposer();
       closeFeedbackComposer();
      hideFab();
     });
     });
     $cmpSubmit.on('click', submitComment);
     $fbSubmit.on('click', submitFeedback);
     $cmpInput.on('keydown', function(e){
     $fbText.on('keydown', function(e){
       // Ctrl+Enter or Cmd+Enter submits
       if (e.key==='Escape') closeFeedbackComposer();
       if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); }
    });
       if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
 
    // ── 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 ─────────────────────────────────────────
     // ── Bookmark composer ─────────────────────────────────────────
     $( '#gra-bm-cancel' ).on('click', function(){
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
      closeBookmarkComposer();
      hideFab();
    });
     $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 close ───────────────────────────────────────────────
     // ── Panel ─────────────────────────────────────────────────────
     $( '#gra-panel-close' ).on('click', closePanel);
     $('#gra-panel-close').on('click', closePanel);
     $backdrop.on('click', closePanel);
     $backdrop.on('click touchend', function(e){
 
      e.preventDefault();
     // ── Tab switching ─────────────────────────────────────────────
      if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
     $tabComments.on('click', function(){ switchTab('comments'); });
      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'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });


    // ── Panel: click comment card → scroll to highlight ───────────
     $paneNotes.on('click', '.gra-note-card', function(e){
     $paneComments.on('click', '.gra-comment-card', function(){
      if ($(e.target).hasClass('gra-note-del')) return;
       var id = $(this).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       if (id) scrollToHighlight(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);
     });
     });
    // ── Panel: click bookmark card → scroll to highlight ──────────
     $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) scrollToHighlight(id);
       if (id) { closePanel(); scrollToHighlight(id); }
     });
     });
    // ── Panel: delete bookmark ────────────────────────────────────
     $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
     $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
       e.stopPropagation();
       e.stopPropagation();
Line 796: Line 778:
     });
     });


    // ── Click on highlight in text → open panel ───────────────────
     $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
     $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
       var id = $(this).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       openPanel('comments');
       openPanel('notes');
      // After render, scroll to matching card
       setTimeout(function(){
       setTimeout(function(){
         var $card = $paneComments.find('[data-gra-id="'+id+'"]');
         var $card = $paneNotes.find('[data-gra-id="'+id+'"]');
         if ($card.length) {
         if ($card.length) {
           $card.addClass('gra-card-active');
           $card.addClass('gra-card-active');
           $card[0].scrollIntoView({behavior:'smooth',block:'nearest'});
           $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
           setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
           setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
         }
         }
       }, 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(){
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
         if ($card.length) $card[0].scrollIntoView({behavior:'smooth',block:'nearest'});
         if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
       }, 100);
       }, 100);
     });
     });


    // ── Escape key closes panels / composers ─────────────────────
     $(document).on('keydown', function(e){
     $( document ).on('keydown', function(e){
       if (e.key !== 'Escape') return;
       if (e.key==='Escape'){
      if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
        if ($cmpComposer.hasClass('gra-composer-visible')) { closeCommentComposer(); 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();
      }
     });
     });
   }
   }


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // RESTORE BOOKMARK HIGHLIGHTS from localStorage on page load
   // RESTORE HIGHLIGHTS
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
  // Bookmarks store the quote text. We do a best-effort text search
  // in the content to re-wrap the same span after page reload.
  // (Comments are server-stored and we only re-render cards, not re-wrap.)


  // ── Persist comment highlight anchors to localStorage ────────────────
   function persistNoteHighlight(id, quote) {
  // We only store {id, quote} — the full comment data lives on the wiki.
  // On reload, restoreCommentHighlights re-wraps the quote text in the page
  // so the yellow highlight appears again.
 
   function persistCommentHighlight( id, quote ) {
     try {
     try {
       var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
       var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
       // Deduplicate
       s = s.filter(function(h){ return h.id !== id; });
      stored = stored.filter( function(h){ return h.id !== id; } );
       s.push({id:id, quote:quote});
       stored.push( { id: id, quote: quote } );
       localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
       localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
     } catch(e){}
     } catch(e){}
   }
   }


   function restoreCommentHighlights() {
   function restoreNoteHighlights() {
     var stored = [];
     var s = [];
     try { stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' ); } catch(e){}
     try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){}
     stored.forEach( function(h) {
     s.forEach(function(h){
       if ( !h.quote || !h.id ) return;
       if (!h.quote || !h.id) return;
       if ( document.querySelector( '[data-gra-id="' + h.id + '"].gra-comment-highlight' ) ) return;
       if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return;
       var needle = h.quote.replace(/…$/, '').trim().slice(0, 80);
       var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
       if ( !needle ) return;
       if (!needle) return;
       var range = findTextInContent( document.querySelector(CONTENT_SEL), needle );
       var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       if ( range ) {
       if (range) {
         var span = document.createElement('span');
         var sp = document.createElement('span');
         span.className = 'gra-comment-highlight';
         sp.className = 'gra-note-highlight';
         span.setAttribute('data-gra-id', h.id);
         sp.setAttribute('data-gra-id', h.id);
         try { range.surroundContents(span); } catch(e){}
         try { range.surroundContents(sp); } catch(e){}
       }
       }
     });
     });
Line 872: Line 841:
   function restoreBookmarkHighlights() {
   function restoreBookmarkHighlights() {
     _bookmarks.forEach(function(b){
     _bookmarks.forEach(function(b){
       if ( !b.quote ) return;
       if (!b.quote) return;
      // Already highlighted from this session
       if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return;
       if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return;
      // Text search — find first occurrence of quote in content
      var contentEl = document.querySelector( CONTENT_SEL );
      if (!contentEl) return;
       var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
       var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
       if (!needle) return;
       if (!needle) return;
      // Walk text nodes to find a match
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var found = findTextInContent( contentEl, needle );
       if (found) {
       if (found) {
         var span = document.createElement('span');
         var sp = document.createElement('span');
         span.className = 'gra-bookmark-highlight';
         sp.className = 'gra-bookmark-highlight';
         span.setAttribute('data-gra-id', b.id);
         sp.setAttribute('data-gra-id', b.id);
         span.setAttribute('data-gra-name', b.name);
         sp.setAttribute('data-gra-name', b.name);
         try {
         try { found.surroundContents(sp); } catch(e){}
          found.surroundContents(span);
        } catch(e){}
       }
       }
     });
     });
   }
   }


   function findTextInContent( root, needle ) {
   function findTextInContent(root, needle) {
     // Returns a Range covering the first occurrence of needle in root's text
     if (!root) return null;
     var text = root.textContent || '';
     var text = root.textContent || '';
     var idx  = text.indexOf(needle);
     var idx  = text.indexOf(needle);
     if (idx < 0) return null;
     if (idx < 0) return null;
    // Walk to find the exact text nodes
     var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
     var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
     var pos = 0;
     var pos = 0, node, startNode, startOffset, endNode, endOffset;
    var node, startNode, startOffset, endNode, endOffset;
     while ((node = iter.nextNode())) {
     while ((node = iter.nextNode())) {
       var len = node.nodeValue.length;
       var len = node.nodeValue.length;
       if (!startNode && pos+len > idx) {
       if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; }
        startNode   = node;
        startOffset = idx - pos;
      }
       var endIdx = idx + needle.length;
       var endIdx = idx + needle.length;
       if (startNode && pos+len >= endIdx) {
       if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; }
        endNode   = node;
        endOffset = endIdx - pos;
        break;
      }
       pos += len;
       pos += len;
     }
     }
     if (!startNode || !endNode) return null;
     if (!startNode || !endNode) return null;
     var range = document.createRange();
     var r = document.createRange();
     range.setStart(startNode, startOffset);
     r.setStart(startNode, startOffset);
     range.setEnd(endNode, endOffset);
     r.setEnd(endNode, endOffset);
     return range;
     return r;
   }
   }


Line 928: Line 881:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   $( 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();
    loadNotes();
     loadBookmarks();
     loadBookmarks();
    restoreNoteHighlights();
     restoreBookmarkHighlights();
     restoreBookmarkHighlights();
    restoreCommentHighlights();
   });
    // Pre-load comment count in background
    loadComments(function(){
      // Update tab label with count
      if (_comments.length > 0) {
        $tabComments.find('span.gra-icon').after(
          ' <span style="background:#e53935;color:#fff;border-radius:9px;' +
          'font-size:10px;padding:0 5px;margin-left:2px;">' + _comments.length + '</span>'
        );
        // Update toggle badge
        var $badge = $( '#gra-toggle-badge' );
        if ( $badge.length ) $badge.text( _comments.length ).css('display','flex');
      }
    });
   } );


}() );
}() );

Latest revision as of 18:51, 14 May 2026

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

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

  // ── Configuration ────────────────────────────────────────────────────
  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;
  }

  // ── State ────────────────────────────────────────────────────────────
  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;   // set immediately

  // ── Helpers ──────────────────────────────────────────────────────────
  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; }

  // ── DOM references ───────────────────────────────────────────────────
  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;

  // ════════════════════════════════════════════════════════════════════
  // DOM BUILDER
  // ════════════════════════════════════════════════════════════════════

  function buildDom() {
    // ── Desktop FAB strip ────────────────────────────────────────────
    $fab = $( [
      '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
      '  <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-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-fab-tooltip">Note</span>',
      '  </button>',
      '  <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-fab-tooltip">Bookmark</span>',
      '  </button>',
      '</div>',
    ].join('') );
    $('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 (centered modal) ───────────────────────────
    $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);

    // ── Note composer ────────────────────────────────────────────────
    $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 || 'Note') + '</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);

    // ── Bookmark composer ─────────────────────────────────────────────
    $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);

    // ── Right panel ───────────────────────────────────────────────────
    $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');
  }

  // ════════════════════════════════════════════════════════════════════
  // SELECTION
  // ════════════════════════════════════════════════════════════════════

  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 start = range.commonAncestorContainer;
    if (start.nodeType === 3) start = start.parentNode;
    if (!contentEl.contains(start)) return false;
    _selText  = text;
    _selRange = range.cloneRange();
    _selRect  = range.getBoundingClientRect();
    return true;
  }

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

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

  function showFab(rect) {
    if (_mobile) return;   // mobile uses bottom bar
    if (!rect) return;
    var fabW = 46, fabH = 126;
    var top  = rect.top + (rect.height/2) - (fabH/2);
    var 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'); }

  // ════════════════════════════════════════════════════════════════════
  // MOBILE BOTTOM BAR
  // ════════════════════════════════════════════════════════════════════

  function showMobileBar() {
    $mobileBar.addClass('gra-mobile-bar-visible');
  }

  function hideMobileBar() {
    $mobileBar.removeClass('gra-mobile-bar-visible');
  }

  function hideActions() {
    hideFab();
    hideMobileBar();
  }

  // Debug helper — remove after testing
  window._graDebug = function() {
    console.log('mobile:', _mobile, 'selText:', _selText, 'selRange:', _selRange);
    console.log('bar visible:', document.getElementById('gra-mobile-bar') &&
      document.getElementById('gra-mobile-bar').classList.contains('gra-mobile-bar-visible'));
  };

  // ════════════════════════════════════════════════════════════════════
  // 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 top  = _selRect.bottom + 8;
    var left = _selRect.left;
    if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
    left = Math.max(left, 8);
    if (top + 280 > window.innerHeight) top = _selRect.top - 290;
    top  = Math.max(top, 8);
    $el.css({top: top+'px', left: left+'px'});
  }

  // ════════════════════════════════════════════════════════════════════
  // WRAP SELECTION
  // ════════════════════════════════════════════════════════════════════

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

  // ════════════════════════════════════════════════════════════════════
  // FEEDBACK FLOW
  // ════════════════════════════════════════════════════════════════════

  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('');
    $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');
  }

  // ════════════════════════════════════════════════════════════════════
  // NOTE FLOW
  // ════════════════════════════════════════════════════════════════════

  function openNoteComposer() {
    hideActions();
    // Centered modal on all devices
    $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 ? '…' : '');
    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){}
  }

  // ════════════════════════════════════════════════════════════════════
  // BOOKMARK FLOW
  // ════════════════════════════════════════════════════════════════════

  function openBookmarkComposer() {
    hideActions();
    // Centered modal — same as feedback, no position anchoring
    $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 ? '…' : '');
    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) {
      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){}
  }

  // ════════════════════════════════════════════════════════════════════
  // PANEL
  // ════════════════════════════════════════════════════════════════════

  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();
  }

  // ════════════════════════════════════════════════════════════════════
  // RENDER CARDS
  // ════════════════════════════════════════════════════════════════════

  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">'
            + '<div class="gra-avatar">✎</div>'
            + '<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) {
      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);
  }

  // ════════════════════════════════════════════════════════════════════
  // SCROLL TO HIGHLIGHT
  // ════════════════════════════════════════════════════════════════════

  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);
  }

  // ════════════════════════════════════════════════════════════════════
  // EVENT WIRING
  // ════════════════════════════════════════════════════════════════════

  function wireEvents() {

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

    // ── Mobile: selectionchange (debounced 600ms) ─────────────────
    // 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() {
      _selVersion++;
      clearTimeout(_selTimer);
      var v = _selVersion;
      _selTimer = setTimeout(function(){
        if (v !== _selVersion) return;
        if (_fabSelVer === v) return;
        tryShowActions();
      }, 600);   // 600ms — after browser copy menu appears
    });

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

    // ── Desktop FAB buttons ───────────────────────────────────────
    $('#gra-fab-feedback').on('click', function(e){
      e.preventDefault(); e.stopPropagation();
      if (!_selRange && !captureSelection()) return;
      openFeedbackComposer();
    });
    $('#gra-fab-note').on('click', function(e){
      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();
    });

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

    // ── Feedback composer ─────────────────────────────────────────
    $fbIssueType.on('change', function(){
      $fbSubmit.prop('disabled', !$(this).val());
    });
    $('#gra-fb-cancel, #gra-fb-close').on('click', function(){
      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();
    });
  }

  // ════════════════════════════════════════════════════════════════════
  // RESTORE HIGHLIGHTS
  // ════════════════════════════════════════════════════════════════════

  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) {
        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) {
        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) 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;
    var r = document.createRange();
    r.setStart(startNode, startOffset);
    r.setEnd(endNode, endOffset);
    return r;
  }

  // ════════════════════════════════════════════════════════════════════
  // BOOT
  // ════════════════════════════════════════════════════════════════════

  $(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();
    wireEvents();
    loadNotes();
    loadBookmarks();
    restoreNoteHighlights();
    restoreBookmarkHighlights();
  });

}() );