MediaWiki:Gadget-GrAnnotations.js

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v3)
 * ══════════════════════════════════════════════════════════════════════
 *
 * CHANGES FROM v2
 * ────────────────
 * • "Comments" renamed to "Notes" throughout. Notes are user-local
 *   (localStorage only, not saved to wiki Talk pages).
 * • Comment icon replaced with notes.svg (same path convention).
 * • New "Feedback" button (flag icon) replaces the old comment FAB button.
 *   Shows a popup with:
 *     - Highlighted text (read-only)
 *     - Issue type: Wrong text / Reference issue / Spelling mistake / Other
 *     - Free-text area
 *     - Email field for user's address
 *   Submits via EmailJS / fetch to admin@anandamakaranda.com.
 *   NOT stored anywhere on the wiki. NOT shown in the panel.
 * • Notes panel tab uses notes.svg icon.
 * ══════════════════════════════════════════════════════════════════════
 */

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

  // ── Configuration ────────────────────────────────────────────────────
  var ADMIN_EMAIL   = 'admin@anandamakaranda.com';
  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() : '?';

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

  // ── State ────────────────────────────────────────────────────────────
  var _selRange   = null;
  var _selText    = '';
  var _selRect    = null;
  var _notes      = [];       // was _comments, now local only
  var _bookmarks  = [];
  var _activeTab  = 'notes';
  var _selVersion = 0;
  var _fabSelVer  = -1;

  // ── 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 window.innerWidth < 768 || 'ontouchstart' in window; }

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

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

  function buildDom() {

    // ── FAB strip — Feedback (flag) + Note (notes) + Bookmark ───────
    $fab = $( [
      '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
      // Feedback button — flag icon
      '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '    <span class="gra-fab-tooltip">Feedback</span>',
      '  </button>',
      // Note button — notes icon
      '  <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>',
      // Bookmark 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 );

    // ── Feedback composer (popup) ─────────────────────────────────────
    $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">Wrong text</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" id="gra-nt-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 );

    // Toggle button (bottom-right, persistent) — notes icon
    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 );
    } );

    // Cache refs
    $( '#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 tryShowFab() {
    if ( $fbComposer.hasClass('gra-composer-visible') ) return;
    if ( $ntComposer.hasClass('gra-composer-visible') ) return;
    if ( $bmComposer.hasClass('gra-composer-visible') ) return;
    if ( captureSelection() ) {
      _fabSelVer = _selVersion;
      showFab( _selRect );
    } else {
      hideFab();
    }
  }

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

  function showFab( rect ) {
    if ( !rect ) return;
    var fabW = 46, fabH = 126;   // 3 buttons now
    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'); }

  // ════════════════════════════════════════════════════════════════════
  // COMPOSER POSITIONING
  // ════════════════════════════════════════════════════════════════════

  function positionComposer( $el ) {
    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 );
    if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2;
    $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  (sends email, not stored on wiki)
  // ════════════════════════════════════════════════════════════════════

  function openFeedbackComposer() {
    hideFab();
    // Show selected text in the popup
    $fbQuote.text( _selText.slice(0, 200) + (_selText.length > 200 ? '…' : '') );
    $fbIssueType.val('');
    $fbText.val('');
    $fbEmail.val('');
    $fbSubmit.prop('disabled', true);
    $( '#gra-fb-status' ).text('').removeClass('gra-fb-ok gra-fb-err');
    positionComposer( $fbComposer );
    $fbComposer.addClass('gra-composer-visible');
    setTimeout( function() { $fbIssueType.focus(); }, isMobile() ? 300 : 0 );
  }

  function closeFeedbackComposer() {
    $fbComposer.removeClass('gra-composer-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:       'Wrong text',
      reference_issue:  'Reference issue',
      spelling_mistake: 'Spelling mistake',
      other:            'Other'
    };

    var body = [
      'Page: ' + pageTitle.replace(/_/g,' '),
      'URL: ' + window.location.href,
      'Issue type: ' + ( issueLabels[issueType] || issueType ),
      'Selected text: ' + quote,
      'Details: ' + ( details || '(none)' ),
      'User email: ' + ( email || '(not provided)' ),
      'User: ' + ( currentUser || 'anonymous' ),
      'Timestamp: ' + new Date().toISOString(),
    ].join('\n');

    // ── Send via mailto as primary (works without backend) ────────
    // Opens the user's mail client silently in a hidden iframe so it
    // doesn't navigate away. Falls back gracefully if blocked.
    var subject = encodeURIComponent(
      '[Grantha Feedback] ' + ( issueLabels[issueType] || issueType )
      + ' — ' + pageTitle.replace(/_/g,' ').slice(0,40)
    );
    var mailBody = encodeURIComponent(body);
    var mailtoUrl = 'mailto:' + ADMIN_EMAIL
                  + '?subject=' + subject
                  + '&body=' + mailBody;

    // Try MW EmailUser API first (server-side, cleaner)
    if ( window.mw && mw.config.get('wgUserName') ) {
      var api = new mw.Api();
      api.postWithEditToken({
        action: 'emailuser',
        target: 'Chandrashekars',
        subject: '[Grantha Feedback] ' + ( issueLabels[issueType] || issueType )
                + ' — ' + pageTitle.replace(/_/g,' ').slice(0,40),
        text: body,
        ccme: 0,
      }).then(function() {
        showFeedbackSuccess();
      }).catch(function() {
        // Fall back to mailto
        window.open( mailtoUrl, '_blank' );
        showFeedbackSuccess();
      });
    } else {
      // Anonymous: open mailto
      window.open( mailtoUrl, '_blank' );
      showFeedbackSuccess();
    }
  }

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

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

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

  function closeNoteComposer() {
    $ntComposer.removeClass('gra-composer-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);
    var entry = { id:id, ts:ts, quote:quote, text:text };
    _notes.push( entry );
    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() {
    hideFab();
    positionComposer( $bmComposer );
    $bmComposer.addClass('gra-composer-visible');
    setTimeout( function() { $bmInput.focus(); }, isMobile() ? 300 : 0 );
  }

  function closeBookmarkComposer() {
    $bmComposer.removeClass('gra-composer-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 parent = span.parentNode;
      while (span.firstChild) parent.insertBefore(span.firstChild, span);
      parent.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 click ✎ 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 parent = span.parentNode;
      while (span.firstChild) parent.insertBefore(span.firstChild, span);
      parent.removeChild(span);
    }
    // Remove from highlight persistence
    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 click 🔖 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 / keyup ──────────────────────────────────
    $( document ).on('mouseup keyup', function(e){
      if ( e.type === 'mouseup' && e.button !== 0 ) return;
      setTimeout( tryShowFab, 20 );
    });

    // ── Mobile: selectionchange ───────────────────────────────────
    var _selChangeTimer = null;
    document.addEventListener('selectionchange', function() {
      _selVersion++;
      clearTimeout( _selChangeTimer );
      var v = _selVersion;
      _selChangeTimer = setTimeout(function(){
        if ( v !== _selVersion ) return;
        if ( _fabSelVer === v ) return;
        tryShowFab();
      }, 400);
    });

    // ── Mobile: touchend fallback ─────────────────────────────────
    document.addEventListener('touchend', function(e){
      if ( $fab[0] && $fab[0].contains(e.target) ) return;
      if ( $fbComposer[0] && $fbComposer[0].contains(e.target) ) return;
      if ( $ntComposer[0] && $ntComposer[0].contains(e.target) ) return;
      if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return;
      setTimeout( tryShowFab, 80 );
    }, { passive: true });

    // ── Click outside → hide FAB ──────────────────────────────────
    $( document ).on('mousedown touchstart', function(e){
      var t = e.target;
      if ( $fab[0] && $fab[0].contains(t) ) return;
      if ( $fbComposer[0] && $fbComposer[0].contains(t) ) return;
      if ( $ntComposer[0] && $ntComposer[0].contains(t) ) return;
      if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return;
      hideFab();
    });

    // ── FAB buttons ───────────────────────────────────────────────
    $( '#gra-fab-feedback' ).on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      if ( !_selRange && !captureSelection() ) return;
      openFeedbackComposer();
    });
    $( '#gra-fab-note' ).on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      if ( !_selRange && !captureSelection() ) return;
      openNoteComposer();
    });
    $( '#gra-fab-bookmark' ).on('click touchend', function(e){
      e.preventDefault(); e.stopPropagation();
      if ( !_selRange && !captureSelection() ) return;
      openBookmarkComposer();
    });

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

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

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

    // ── Panel ─────────────────────────────────────────────────────
    $( '#gra-panel-close' ).on('click', closePanel);
    $backdrop.on('click touchstart', closePanel);
    $tabNotes.on('click', function(){ switchTab('notes'); });
    $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });

    // Click note card → scroll to highlight
    $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); }
    });
    // Delete note
    $paneNotes.on('click', '.gra-note-del', function(e){
      e.stopPropagation();
      var id = $( this ).attr('data-del-id');
      if (id) deleteNote(id);
    });
    // Click bookmark card → scroll
    $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); }
    });
    // Delete bookmark
    $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
      e.stopPropagation();
      var id = $( this ).attr('data-del-id');
      if (id) deleteBookmark(id);
    });

    // Click note highlight in text → open panel
    $( 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);
    });

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

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

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

}() );