MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
 
(38 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v4)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback  (v6 + Strategy B)
* ══════════════════════════════════════════════════════════════════════
*
* 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.
  */
  */


Line 19: Line 7:
   'use strict';
   'use strict';


  // ── Configuration ────────────────────────────────────────────────────
   var CONTENT_SEL  = '#mw-content-text';
   var CONTENT_SEL  = '#mw-content-text';
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
Line 29: Line 16:


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


Line 42: Line 28:
   }
   }


  // ── State ────────────────────────────────────────────────────────────
   var _selRange  = null;
   var _selRange  = null;
   var _selText    = '';
   var _selText    = '';
Line 51: Line 36:
   var _selVersion = 0;
   var _selVersion = 0;
   var _fabSelVer  = -1;
   var _fabSelVer  = -1;
   var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;  // set immediately
   var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;
   var _fabTouched = false;  // flag to prevent hideActions when tapping fab


  // ── Helpers ──────────────────────────────────────────────────────────
   function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
   function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
   function esc(s) {
   function esc(s) {
Line 70: Line 55:
   function isMobile() { return _mobile; }
   function isMobile() { return _mobile; }


  // ── DOM references ───────────────────────────────────────────────────
   var $fab, $mobileBar, $panel, $backdrop;
   var $fab, $mobileBar, $panel, $backdrop;
   var $ntComposer, $ntInput, $ntSubmit;
   var $ntComposer, $ntInput, $ntSubmit;
Line 76: Line 60:
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
   var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;
   var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;
  // ════════════════════════════════════════════════════════════════════
  // DOM BUILDER
  // ════════════════════════════════════════════════════════════════════


   function buildDom() {
   function buildDom() {
    // ── Desktop FAB strip (hidden on mobile) ─────────────────────────
     $fab = $( [
     $fab = $( [
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
       '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">',
       '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">',
      '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Note">',
      '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Note</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Mark">',
      '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Mark</span>',
      '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Feedback">',
       '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Feedback</span>',
       '    <span class="gra-fab-btn-label">Feedback</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Add note">',
       '  <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search">',
       '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Note</span>',
       '    <span class="gra-fab-btn-label">Search</span>',
       '  </button>',
       '  </button>',
       '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">',
       '  <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">',
       '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
       '    <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>',
       '    <span class="gra-fab-tooltip">Bookmark</span>',
       '    <span class="gra-fab-btn-label">Close</span>',
       '  </button>',
       '  </button>',
       '</div>',
       '</div>',
Line 101: Line 88:
     $('body').append($fab);
     $('body').append($fab);


    // ── Mobile bottom action bar (shown instead of FAB on mobile) ────
     $mobileBar = $('<div id="gra-mobile-bar"></div>');
    // 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);
     $('body').append($mobileBar);


    // ── Feedback composer (centered modal) ───────────────────────────
     $fbComposer = $( [
     $fbComposer = $( [
       '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
       '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
Line 159: Line 121:
     $('body').append($fbComposer);
     $('body').append($fbComposer);


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


    // ── Bookmark composer ─────────────────────────────────────────────
     $bmComposer = $( [
     $bmComposer = $( [
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
       '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
Line 191: Line 151:
     $('body').append($bmComposer);
     $('body').append($bmComposer);


    // ── Right panel ───────────────────────────────────────────────────
     $panel = $( [
     $panel = $( [
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
Line 243: Line 202:
     $fbQuote      = $('#gra-fb-quote');
     $fbQuote      = $('#gra-fb-quote');
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // SELECTION
  // ════════════════════════════════════════════════════════════════════


   function captureSelection() {
   function captureSelection() {
Line 256: Line 211:
     var contentEl = document.querySelector(CONTENT_SEL);
     var contentEl = document.querySelector(CONTENT_SEL);
     if (!contentEl) return false;
     if (!contentEl) return false;
     var start = range.commonAncestorContainer;
     var ancestor = range.commonAncestorContainer;
     if (start.nodeType === 3) start = start.parentNode;
     if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
     if (!contentEl.contains(start)) return false;
     if (!ancestor || !contentEl.contains(ancestor)) return false;
    var _editorEl = document.getElementById('se-surface') ||
                    document.querySelector('.se-outer');
    if ( _editorEl && _editorEl.contains(ancestor) ) return false;
     _selText  = text;
     _selText  = text;
    _selRange = range.cloneRange();
     _selRect  = range.getBoundingClientRect();
     _selRect  = range.getBoundingClientRect();
    try { _selRange = range.cloneRange(); }
    catch(e){ _selRange = null; }
    return true;
  }
  function reCaptureFromDOM() {
    if (!_selText) return false;
    var contentEl = document.querySelector(CONTENT_SEL);
    if (!contentEl) return false;
    var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim());
    if (!found) return false;
    _selRange = found;
     return true;
     return true;
   }
   }


   function tryShowActions() {
   function tryShowActions() {
     if ($fbComposer.hasClass('gra-composer-visible')) return;
     if ($fbComposer && $fbComposer.hasClass('gra-composer-visible')) return;
     if ($ntComposer.hasClass('gra-composer-visible')) return;
     if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return;
     if ($bmComposer.hasClass('gra-composer-visible')) return;
     if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return;
     if (!captureSelection()) {
     if (!captureSelection()) { hideActions(); return; }
      hideActions();
      return;
    }
     _fabSelVer = _selVersion;
     _fabSelVer = _selVersion;
    // CSS hides the one that's not appropriate for the device
    showMobileBar();
     showFab(_selRect);
     showFab(_selRect);
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // DESKTOP FAB
  // ════════════════════════════════════════════════════════════════════


   function showFab(rect) {
   function showFab(rect) {
    if (_mobile) return;  // mobile uses bottom bar instead
     if (!rect) return;
     if (!rect) return;
     var fabW = 46, fabH = 126;
     var fabW, fabH, top, left;
     var top  = rect.top + (rect.height/2) - (fabH/2);
    if (_mobile) {
     var left = rect.right + 10;
      /* Docked as a fixed bar below the reader toolbar.
        All positioning is handled by CSS via .gra-fab-mobile-docked,
        so it never collides with the native selection menu and never
        clips at screen edges or causes horizontal scroll. */
      $fab.css({ position: '', top: '', left: '', visibility: '' })
          .addClass('gra-fab-visible gra-fab-mobile-docked');
      return;
    }
    fabW = 46; fabH = 126;
     top  = rect.top + (rect.height / 2) - (fabH / 2);
     left = rect.right + 10;
     if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10;
     if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10;
     top  = clamp(top,  8, window.innerHeight - fabH - 8);
     top  = clamp(top,  8, window.innerHeight - fabH - 8);
     left = clamp(left, 8, window.innerWidth  - fabW - 8);
     left = clamp(left, 8, window.innerWidth  - fabW - 8);
     $fab.css({top: top+'px', left: left+'px'}).addClass('gra-fab-visible');
     $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
  }
 
  function hideFab() { $fab.removeClass('gra-fab-visible'); }
 
  // ════════════════════════════════════════════════════════════════════
  // 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'});
   }
   }


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


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


   function openFeedbackComposer() {
   function openFeedbackComposer() {
Line 382: Line 305:
     if (currentUserEmail) $fbEmail.val(currentUserEmail);
     if (currentUserEmail) $fbEmail.val(currentUserEmail);
     else $fbEmail.val('');
     else $fbEmail.val('');
     $fbComposer.css({top:'', left:'', transform:''});
     if (!_mobile) $fbComposer.css({top:'', left:'', transform:''});
     $fbComposer.addClass('gra-composer-visible');
     $fbComposer.addClass('gra-composer-visible');
     $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
Line 400: Line 323:
     var quote    = $fbQuote.text();
     var quote    = $fbQuote.text();
     if (!issueType) return;
     if (!issueType) return;
     $fbSubmit.prop('disabled', true).text('Sending…');
     $fbSubmit.prop('disabled', true).text('Sending…');
     $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
     $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
     var issueLabels = {
     var issueLabels = {
       wrong_text: 'Formatting error', reference_issue: 'Reference issue',
       wrong_text: 'Formatting error', reference_issue: 'Reference issue',
       spelling_mistake: 'Spelling mistake', other: 'Other'
       spelling_mistake: 'Spelling mistake', other: 'Other'
     };
     };
     var payload = new FormData();
     var payload = new FormData();
     payload.append('issue_type',    issueLabels[issueType] || issueType);
     payload.append('issue_type',    issueLabels[issueType] || issueType);
Line 417: Line 337:
     payload.append('user_email',    email || currentUserEmail || '');
     payload.append('user_email',    email || currentUserEmail || '');
     payload.append('wiki_user',    currentUser || 'anonymous');
     payload.append('wiki_user',    currentUser || 'anonymous');
     fetch('/feedback.php', {method:'POST', body:payload})
     fetch('/feedback.php', {method:'POST', body:payload})
       .then(function(r){ return r.json(); })
       .then(function(r){ return r.json(); })
Line 432: Line 351:
     setTimeout(closeFeedbackComposer, 2500);
     setTimeout(closeFeedbackComposer, 2500);
   }
   }
   function showFeedbackError(msg) {
   function showFeedbackError(msg) {
     $fbSubmit.prop('disabled', false).text('Send');
     $fbSubmit.prop('disabled', false).text('Send');
     $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
     $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // NOTE FLOW
  // ════════════════════════════════════════════════════════════════════


   function openNoteComposer() {
   function openNoteComposer() {
     hideActions();
     hideActions();
     positionComposer($ntComposer);
     if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' });
     $ntComposer.addClass('gra-composer-visible');
     $ntComposer.addClass('gra-composer-visible');
     if (isMobile()) $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
     setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
   }
   }
Line 464: Line 378:
     var ts    = nowIso();
     var ts    = nowIso();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
     var span  = wrapSelection(id, 'gra-note-highlight');
     var span  = wrapSelection(id, 'gra-note-highlight');
     if (span) span.setAttribute('data-gra-quote', quote);
     if (span) span.setAttribute('data-gra-quote', quote);
Line 480: Line 395:
     try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
     try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // BOOKMARK FLOW
  // ════════════════════════════════════════════════════════════════════


   function openBookmarkComposer() {
   function openBookmarkComposer() {
     hideActions();
     hideActions();
     positionComposer($bmComposer);
     if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
     $bmComposer.addClass('gra-composer-visible');
     $bmComposer.addClass('gra-composer-visible');
     if (isMobile()) $backdrop.addClass('gra-backdrop-visible');
     $backdrop.addClass('gra-backdrop-visible');
     setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
     setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
   }
   }
Line 504: Line 415:
     var id    = uid();
     var id    = uid();
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
     var span  = wrapSelection(id, 'gra-bookmark-highlight');
     var span  = wrapSelection(id, 'gra-bookmark-highlight');
     if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
     if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
Line 516: Line 428:
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
     if (span) {
     if (span && span.parentNode) {
       var p = span.parentNode;
       var p = span.parentNode;
       while (span.firstChild) p.insertBefore(span.firstChild, span);
       while (span.firstChild) p.insertBefore(span.firstChild, span);
Line 530: Line 442:
     try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
     try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // PANEL
  // ════════════════════════════════════════════════════════════════════


   function openPanel(tab) {
   function openPanel(tab) {
Line 554: Line 462:
     else renderBookmarkCards();
     else renderBookmarkCards();
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RENDER CARDS
  // ════════════════════════════════════════════════════════════════════


   function renderNoteCards() {
   function renderNoteCards() {
Line 568: Line 472:
       html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
       html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
             + '<div class="gra-card-header">'
             + '<div class="gra-card-header">'
             + '<div class="gra-avatar"></div>'
             + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>'
             + '<div class="gra-card-meta">'
             + '<div class="gra-card-meta">'
             + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
             + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
Line 584: Line 488:
     _notes = _notes.filter(function(n){ return n.id !== id; });
     _notes = _notes.filter(function(n){ return n.id !== id; });
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
     if (span) {
     if (span && span.parentNode) {
       var p = span.parentNode;
       var p = span.parentNode;
       while (span.firstChild) p.insertBefore(span.firstChild, span);
       while (span.firstChild) p.insertBefore(span.firstChild, span);
Line 615: Line 519:
     $paneBookmarks.html(html);
     $paneBookmarks.html(html);
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // SCROLL TO HIGHLIGHT
  // ════════════════════════════════════════════════════════════════════


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


   // ════════════════════════════════════════════════════════════════════
   function wireEvents() {
  // EVENT WIRING
  // ════════════════════════════════════════════════════════════════════


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


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


     // ── Mobile: selectionchange (debounced 600ms) ─────────────────
     /* Separate timers so mobile + desktop never clobber each other */
     // We wait longer than v3 so the browser's own copy menu has time
    var _selTimer    = null;  /* desktop debounce */
    // to appear first — user can still copy, THEN our bar slides up.
    var _mobShowTimer = null; /* mobile show-on-touchend */
    var _selTimer = null;
 
    /* Mobile: show fab quickly after finger lifts (selection settled).
      180ms feels instant while still letting the range stabilise. */
    document.addEventListener('touchend', function(e) {
      if (!_mobile) return;
      if ($fab[0] && $fab[0].contains(e.target)) return;
      clearTimeout(_mobShowTimer);
      _mobShowTimer = setTimeout(function() {
        var sel = window.getSelection();
        if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
        tryShowActions();
      }, 180);
    }, { passive: true });
 
     /* Mobile: only HIDE the fab when selection is cleared while it's visible.
      (Reposition isn't needed now that the bar is docked, and re-running
      showFab here was causing the lag/flicker.) */
    document.addEventListener('selectionchange', function() {
      if (!_mobile) return;
      if (!$fab.hasClass('gra-fab-visible')) return;
      var sel = window.getSelection();
      if (!sel || sel.isCollapsed || !sel.toString().trim()) {
        clearTimeout(_mobShowTimer);
        hideActions();
      }
    });
 
    /* selectionchange debounced (desktop only) */
     document.addEventListener('selectionchange', function() {
     document.addEventListener('selectionchange', function() {
      if (_mobile) return;
       _selVersion++;
       _selVersion++;
       clearTimeout(_selTimer);
       clearTimeout(_selTimer);
Line 652: Line 584:
         if (v !== _selVersion) return;
         if (v !== _selVersion) return;
         if (_fabSelVer === v) return;
         if (_fabSelVer === v) return;
         tryShowActions();   // works for both mobile and desktop
         tryShowActions();
       }, 600);   // 600ms — after browser copy menu appears
       }, 600);
     });
     });


     // ── Click outside → hide actions ──────────────────────────────
     /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */
    $fab[0].addEventListener('touchstart', function(e) {
      _fabTouched = true;
      /* Don't propagate to document handler */
      e.stopPropagation();
    }, { passive: true });
 
    /* Click outside → hide actions (blocked if fab was touched) */
     $(document).on('mousedown touchstart', function(e){
     $(document).on('mousedown touchstart', function(e){
      if (_fabTouched) { _fabTouched = false; return; }
       var t = e.target;
       var t = e.target;
       if ($fab[0]         && $fab[0].contains(t))         return;
       if ($fab[0]       && $fab[0].contains(t))       return;
      if ($mobileBar[0]  && $mobileBar[0].contains(t))  return;
       if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
       if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
       if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
       if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
       if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
       if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
       hideActions();
       hideActions();
     });
     });


     // ── Desktop FAB buttons ───────────────────────────────────────
     /* ── FAB buttons — use touchend for mobile, click for desktop ── */
     $('#gra-fab-feedback').on('click', function(e){
    function fabAction(btnId, action) {
      var el = document.getElementById(btnId);
      if (!el) return;
      /* touchend: fires before document touchstart clears _selRange */
      el.addEventListener('touchend', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
        action();
      }, { passive: false });
      /* click: for desktop */
      el.addEventListener('click', function(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!_selRange && !reCaptureFromDOM()) return;
        action();
      });
    }
 
     fabAction('gra-fab-note',    openNoteComposer);
    fabAction('gra-fab-bookmark', openBookmarkComposer);
    fabAction('gra-fab-feedback', openFeedbackComposer);
 
    document.getElementById('gra-fab-search').addEventListener('touchend', function(e) {
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if (!_selRange && !captureSelection()) return;
      var q = _selText;
      openFeedbackComposer();
      hideActions();
     });
      _selRange = null; _selText = ''; _selRect = null;
     $('#gra-fab-note').on('click', function(e){
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
     }, { passive: false });
     document.getElementById('gra-fab-search').addEventListener('click', function(e) {
       e.preventDefault(); e.stopPropagation();
       e.preventDefault(); e.stopPropagation();
       if (!_selRange && !captureSelection()) return;
       var q = _selText;
       openNoteComposer();
       hideActions();
    });
      _selRange = null; _selText = ''; _selRect = null;
    $('#gra-fab-bookmark').on('click', function(e){
       if (q && window.showSearchDialog) { window.showSearchDialog(q); }
       e.preventDefault(); e.stopPropagation();
       else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
       if (!_selRange && !captureSelection()) return;
      openBookmarkComposer();
     });
     });


     // ── Mobile bottom bar buttons ─────────────────────────────────
     /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
     $('#gra-mob-feedback').on('click touchend', function(e){
     (function () {
       e.preventDefault(); e.stopPropagation();
       var dismissEl = document.getElementById('gra-fab-dismiss');
      hideMobileBar();
       if (!dismissEl) return;
       if (!_selRange && !captureSelection()) return;
       function doDismiss(e) {
       openFeedbackComposer();
        e.preventDefault(); e.stopPropagation();
    });
        hideActions();
    $('#gra-mob-note').on('click touchend', function(e){
        _selRange = null; _selText = ''; _selRect = null;
      e.preventDefault(); e.stopPropagation();
        if (window.getSelection) {
      hideMobileBar();
          var s = window.getSelection();
      if (!_selRange && !captureSelection()) return;
          if (s && s.removeAllRanges) s.removeAllRanges();
      openNoteComposer();
        }
    });
       }
    $('#gra-mob-bookmark').on('click touchend', function(e){
      dismissEl.addEventListener('touchend', doDismiss, { passive: false });
      e.preventDefault(); e.stopPropagation();
       dismissEl.addEventListener('click', doDismiss);
      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 ─────────────────────────────────────────
     /* Feedback composer */
     $fbIssueType.on('change', function(){
     $fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); });
      $fbSubmit.prop('disabled', !$(this).val());
     $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer);
    });
     $('#gra-fb-cancel, #gra-fb-close').on('click', function(){
      closeFeedbackComposer();
    });
     $fbSubmit.on('click', submitFeedback);
     $fbSubmit.on('click', submitFeedback);
     $fbText.on('keydown', function(e){
     $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); });
      if (e.key==='Escape') closeFeedbackComposer();
    });


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


     // ── Bookmark composer ─────────────────────────────────────────
     /* Bookmark composer */
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
     $bmSubmit.on('click', submitBookmark);
     $bmSubmit.on('click', submitBookmark);
Line 741: Line 686:
     });
     });


     // ── Panel ─────────────────────────────────────────────────────
     /* Panel */
     $('#gra-panel-close').on('click', closePanel);
     $('#gra-panel-close').on('click', closePanel);
     $backdrop.on('click touchend', function(e){
     $backdrop.on('click touchend', function(e){
Line 803: Line 748:
     });
     });
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // RESTORE HIGHLIGHTS
  // ════════════════════════════════════════════════════════════════════


   function persistNoteHighlight(id, quote) {
   function persistNoteHighlight(id, quote) {
Line 826: Line 767:
       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) return;
        var sp = document.createElement('span');
      var sp = document.createElement('span');
        sp.className = 'gra-note-highlight';
      sp.className = 'gra-note-highlight';
        sp.setAttribute('data-gra-id', h.id);
      sp.setAttribute('data-gra-id', h.id);
        try { range.surroundContents(sp); } catch(e){}
      try { range.surroundContents(sp); } catch(e){}
      }
     });
     });
   }
   }
Line 842: Line 782:
       if (!needle) return;
       if (!needle) return;
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
       if (found) {
       if (!found) return;
        var sp = document.createElement('span');
      var sp = document.createElement('span');
        sp.className = 'gra-bookmark-highlight';
      sp.className = 'gra-bookmark-highlight';
        sp.setAttribute('data-gra-id', b.id);
      sp.setAttribute('data-gra-id', b.id);
        sp.setAttribute('data-gra-name', b.name);
      sp.setAttribute('data-gra-name', b.name);
        try { found.surroundContents(sp); } catch(e){}
      try { found.surroundContents(sp); } catch(e){}
      }
     });
     });
   }
   }


   function findTextInContent(root, needle) {
   function findTextInContent(root, needle) {
     if (!root) return null;
     if (!root || !needle) return null;
     var text = root.textContent || '';
     var text = root.textContent || '';
     var idx  = text.indexOf(needle);
     var idx  = text.indexOf(needle);
Line 867: Line 806:
     }
     }
     if (!startNode || !endNode) return null;
     if (!startNode || !endNode) return null;
     var r = document.createRange();
     try {
    r.setStart(startNode, startOffset);
      var r = document.createRange();
    r.setEnd(endNode, endOffset);
      r.setStart(startNode, startOffset);
     return r;
      r.setEnd(endNode, endOffset);
      return r;
     } catch(e){ return null; }
   }
   }
  // ════════════════════════════════════════════════════════════════════
  // BOOT
  // ════════════════════════════════════════════════════════════════════


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


}() );
}() );