MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
Line 1: Line 1:
/**
/**
  * gr_annotations.js  —  grantha.io inline Comments + Bookmarks  (v2)
  * gr_annotations.js  —  grantha.io inline Notes + Bookmarks + Feedback (v3)
  * ══════════════════════════════════════════════════════════════════════
  * ══════════════════════════════════════════════════════════════════════
  *
  *
  * CHANGES FROM v1
  * CHANGES FROM v2
  * ────────────────
  * ────────────────
  * • Mobile support: selectionchange + touchend events trigger the FAB
  * • "Comments" renamed to "Notes" throughout. Notes are user-local
*  on iOS/Android. Long-press to select text now shows the comment/
  *  (localStorage only, not saved to wiki Talk pages).
  *  bookmark strip correctly.
  * • Comment icon replaced with notes.svg (same path convention).
* • Anonymous comments: any visitor (logged-in or not) can comment.
  * • New "Feedback" button (flag icon) replaces the old comment FAB button.
*  A name field is shown to anonymous users. Comments are stored on
  *   Shows a popup with:
Talk:<PageTitle>/GrComments as before and emailed to
  *     - Highlighted text (read-only)
*  feedback@anandamakaranda.in via mailto: (anon) or MW EmailUser
  *     - Issue type: Wrong text / Reference issue / Spelling mistake / Other
*  (logged-in, falls back to mailto: if email not configured).
  *     - Free-text area
  * • Email target changed to feedback@anandamakaranda.in.
  *     - Email field for user's address
  * • "Add comment" tooltip localised to "टिप्पणी".
  *   Submits via EmailJS / fetch to admin@anandamakaranda.com.
* • "Bookmark" tooltip localised to "चिह्नांकन".
  *   NOT stored anywhere on the wiki. NOT shown in the panel.
  *
  * • Notes panel tab uses notes.svg icon.
  * BEHAVIOUR
* ──────────
* 1. User selects text anywhere in #mw-content-text (desktop mouse or
*    mobile long-press).
  * 2. FAB strip (Comment / Bookmark) appears near the selection.
  * 3. Clicking Comment:
  *   – Opens composer. Anonymous users see a "Your name" field.
  *   – On submit: wraps selection in yellow highlight, saves to wiki,
*      emails feedback@anandamakaranda.in, updates panel.
  * 4. Clicking Bookmark:
*    – Opens name composer. Saves to localStorage, wraps in blue highlight.
  * 5. Right panel (slide-in overlay):
*    – Tab 1: Comments  (loaded from Talk page)
*    – Tab 2: Bookmarks  (localStorage)
*
* STORAGE
* ───────
* Comments  → Talk:<PageTitle>/GrComments  ({{GrComment|…}} templates)
* Bookmarks → localStorage key grantha_bm_<pageName>
* Highlight anchors → localStorage key grantha_cmt_<pageName>
*
* NOTIFICATION
* ────────────
* Logged-in  : MW EmailUser API → feedback@anandamakaranda.in (admin account)
*              Falls back to Talk page notification + mailto: link.
* Anonymous  : Opens mailto:feedback@anandamakaranda.in in a new tab.
*              The comment is still stored on the wiki Talk page.
  * ══════════════════════════════════════════════════════════════════════
  * ══════════════════════════════════════════════════════════════════════
  */
  */
Line 52: Line 25:


   // ── Configuration ────────────────────────────────────────────────────
   // ── Configuration ────────────────────────────────────────────────────
   var FEEDBACK_EMAIL = 'feedback@anandamakaranda.in';
   var ADMIN_EMAIL  = 'admin@anandamakaranda.com';
   var CONTENT_SEL  = '#mw-content-text';
   var CONTENT_SEL  = '#mw-content-text';
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var CMT_LS_KEY    = 'grantha_cmt_' + ( ( 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 pageTitle    = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
  var commentsPage  = 'Talk:' + pageTitle + '/GrComments';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';


  // Run on all content namespaces except the /GrComments 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 ( /\/GrComments$/.test( pageTitle ) ) return;
   }
   }


Line 72: Line 42:
   var _selText    = '';
   var _selText    = '';
   var _selRect    = null;
   var _selRect    = null;
   var _comments  = [];
   var _notes      = [];       // was _comments, now local only
   var _bookmarks  = [];
   var _bookmarks  = [];
  var _cmtLoaded  = false;
   var _activeTab  = 'notes';
   var _activeTab  = 'comments';
   var _selVersion = 0;
 
   var _fabSelVer  = -1;
  // Mobile: track whether FAB was dismissed for the current selection
   var _selVersion = 0;   // incremented on each new selectionchange
   var _fabSelVer  = -1; // the _selVersion when FAB was last shown


   // ── Helpers ──────────────────────────────────────────────────────────
   // ── Helpers ──────────────────────────────────────────────────────────
Line 90: Line 57:
       .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
       .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
   }
   }
   function nowIso() {
   function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); }
    return new Date().toISOString().replace(/\.\d{3}Z$/,'Z');
  }
   function fmtTs( ts ) {
   function fmtTs( ts ) {
     try {
     try {
Line 105: Line 70:
   // ── DOM references ───────────────────────────────────────────────────
   // ── DOM references ───────────────────────────────────────────────────
   var $fab, $panel, $backdrop;
   var $fab, $panel, $backdrop;
   var $cmpComposer, $cmpInput, $cmpSubmit, $cmpName;
   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;
   var $paneNotes, $paneBookmarks;


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
Line 116: Line 82:
   function buildDom() {
   function buildDom() {


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


     // ── Comment composer ─────────────────────────────────────────────
     // ── Feedback composer (popup) ─────────────────────────────────────
     // Anonymous users see an extra "Your name" field (hidden for logged-in)
     $fbComposer = $( [
    var nameRow = currentUser ? '' : [
      '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
       '  <div class="gra-composer-name-row">',
      '  <div class="gra-composer-header">',
       '   <input class="gra-composer-input gra-name-input" id="gra-cmp-name"',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
       '     type="text" placeholder="Your name (optional)" autocomplete="name">',
      '    <strong>Report an Issue</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>',
     ].join('');
      '  <div class="gra-fb-status" id="gra-fb-status"></div>',
      '</div>',
     ].join('') );
    $( 'body' ).append( $fbComposer );


     $cmpComposer = $( [
    // ── Note composer ─────────────────────────────────────────────────
       '<div class="gra-composer" id="gra-cmp-composer" role="dialog" aria-label="Add comment">',
     $ntComposer = $( [
       '<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(currentUser ? userInitial : '?') + '</div>',
       '    <div class="gra-avatar" id="gra-nt-avatar">' + esc(currentUser ? userInitial : '') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Guest') + '</div>',
       '    <div class="gra-composer-uname">' + esc(currentUser || 'Note') + '</div>',
       '  </div>',
       '  </div>',
      nameRow,
       '  <textarea class="gra-composer-input" id="gra-nt-input"',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
       '    placeholder="Write a note…" rows="3"></textarea>',
       '    placeholder="Write a comment…" 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 ─────────────────────────────────────────────
     // ── Bookmark composer ─────────────────────────────────────────────
Line 176: Line 173:
     // ── Right panel ───────────────────────────────────────────────────
     // ── Right panel ───────────────────────────────────────────────────
     $panel = $( [
     $panel = $( [
       '<div id="gra-panel" role="complementary" aria-label="Comments">',
       '<div id="gra-panel" role="complementary" aria-label="Notes">',
       '  <div id="gra-panel-head">',
       '  <div id="gra-panel-head">',
       '    <div id="gra-panel-title"></div>',
       '    <div id="gra-panel-title"></div>',
Line 182: Line 179:
       '  </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" aria-hidden="true"></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">',
Line 190: Line 187:
       '  </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>',
Line 200: Line 197:
     $( 'body' ).append( $backdrop );
     $( 'body' ).append( $backdrop );


     // Toggle FAB (bottom-right, persistent)
     // Toggle button (bottom-right, persistent) — notes icon
     var $toggle = $( [
     var $toggle = $( [
       '<button id="gra-toggle" aria-label="Comments">',
       '<button id="gra-toggle" aria-label="Notes">',
       '  <span class="gra-icon gra-icon-comment" id="gra-toggle-icon" aria-hidden="true"></span>',
       '  <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>',
       '  <span id="gra-toggle-badge" aria-live="polite"></span>',
       '  <span id="gra-toggle-badge" aria-live="polite"></span>',
       '</button>',
       '</button>',
Line 212: Line 209:
     } );
     } );


     // Cache
     // Cache refs
     $( '#gra-panel-title' )
     $( '#gra-panel-title' )
       .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) );
       .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) );
     $tabComments  = $( '#gra-tab-comments' );
     $tabNotes    = $( '#gra-tab-notes' );
     $tabBookmarks = $( '#gra-tab-bookmarks' );
     $tabBookmarks = $( '#gra-tab-bookmarks' );
     $paneComments = $( '#gra-pane-comments' );
     $paneNotes    = $( '#gra-pane-notes' );
     $paneBookmarks= $( '#gra-pane-bookmarks' );
     $paneBookmarks= $( '#gra-pane-bookmarks' );
     $cmpInput    = $( '#gra-cmp-input' );
     $ntInput      = $( '#gra-nt-input' );
     $cmpSubmit    = $( '#gra-cmp-submit' );
     $ntSubmit    = $( '#gra-nt-submit' );
    $cmpName      = $( '#gra-cmp-name' );  // may be empty set for logged-in users
     $bmInput      = $( '#gra-bm-input' );
     $bmInput      = $( '#gra-bm-input' );
     $bmSubmit    = $( '#gra-bm-submit' );
     $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 — desktop + mobile
   // SELECTION
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


Line 236: Line 237:
     var text  = sel.toString().trim();
     var text  = sel.toString().trim();
     if ( !text || text.length < 2 ) return false;
     if ( !text || text.length < 2 ) return false;
     var contentEl = document.querySelector( CONTENT_SEL );
     var contentEl = document.querySelector( CONTENT_SEL );
     if ( !contentEl ) return false;
     if ( !contentEl ) return false;
Line 242: Line 242:
     if ( start.nodeType === 3 ) start = start.parentNode;
     if ( start.nodeType === 3 ) start = start.parentNode;
     if ( !contentEl.contains( start ) ) return false;
     if ( !contentEl.contains( start ) ) return false;
     _selText  = text;
     _selText  = text;
     _selRange = range.cloneRange();
     _selRange = range.cloneRange();
Line 250: Line 249:


   function tryShowFab() {
   function tryShowFab() {
     if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
     if ( $fbComposer.hasClass('gra-composer-visible') ) return;
     if ( $bmComposer.hasClass('gra-composer-visible') ) return;
    if ( $ntComposer.hasClass('gra-composer-visible') ) return;
     if ( $bmComposer.hasClass('gra-composer-visible') ) return;
     if ( captureSelection() ) {
     if ( captureSelection() ) {
       _fabSelVer = _selVersion;
       _fabSelVer = _selVersion;
Line 266: Line 266:
   function showFab( rect ) {
   function showFab( rect ) {
     if ( !rect ) return;
     if ( !rect ) return;
     var fabW = 46, fabH = 84;
     var fabW = 46, fabH = 126;   // 3 buttons now
     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;
Line 283: Line 283:
   function positionComposer( $el ) {
   function positionComposer( $el ) {
     if ( !_selRect ) return;
     if ( !_selRect ) return;
     var W = 308;
     var W   = 340;
     var top  = _selRect.bottom + 8;
     var top  = _selRect.bottom + 8;
     var left = _selRect.left;
     var left = _selRect.left;
     if ( left + W > window.innerWidth - 8 ) left = window.innerWidth - W - 8;
     if ( left + W > window.innerWidth - 8 ) left = window.innerWidth - W - 8;
     left = Math.max( left, 8 );
     left = Math.max( left, 8 );
     if ( top + 200 > window.innerHeight ) top = _selRect.top - 210;
     if ( top + 280 > window.innerHeight ) top = _selRect.top - 290;
     top  = Math.max( top, 8 );
     top  = Math.max( top, 8 );
    // On very small screens center it
     if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2;
     if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2;
     $el.css({ top: top + 'px', left: left + 'px' });
     $el.css({ top: top + 'px', left: left + 'px' });
Line 323: Line 322:


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


   function openCommentComposer() {
   function openFeedbackComposer() {
     hideFab();
     hideFab();
     positionComposer( $cmpComposer );
    // Show selected text in the popup
     $cmpComposer.addClass('gra-composer-visible');
    $fbQuote.text( _selText.slice(0, 200) + (_selText.length > 200 ? '…' : '') );
    // On mobile, delay focus so keyboard doesn't displace the composer
    $fbIssueType.val('');
     setTimeout( function() { $cmpInput.focus(); }, isMobile() ? 300 : 0 );
    $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 closeCommentComposer() {
   function closeFeedbackComposer() {
     $cmpComposer.removeClass('gra-composer-visible');
     $fbComposer.removeClass('gra-composer-visible');
    $cmpInput.val('');
    if ( $cmpName.length ) $cmpName.val('');
    $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 ) return;
    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');


     // Resolve author name: logged-in user or anonymous name or "अतिथि"
     var issueLabels = {
    var author = currentUser
      wrong_text:       'Wrong text',
       || ( $cmpName.length ? $cmpName.val().trim() : '' )
      reference_issue:  'Reference issue',
       || 'Guest';
       spelling_mistake: 'Spelling mistake',
       other:            'Other'
    };


     var id    = uid();
     var body = [
    var ts    = nowIso();
      'Page: ' + pageTitle.replace(/_/g,' '),
    var quote = _selText.slice(0,120) + (_selText.length > 120 ? '' : '');
      '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');


     var span = wrapSelection( id, 'gra-comment-highlight' );
    // ── Send via mailto as primary (works without backend) ────────
     if ( span ) span.setAttribute('data-gra-quote', quote);
    // 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;


     var entry = { id:id, author:author, ts:ts, quote:quote, text:text };
     // Try MW EmailUser API first (server-side, cleaner)
    _comments.push( entry );
    if ( window.mw && mw.config.get('wgUserName') ) {
    persistCommentHighlight( id, quote );
      var api = new mw.Api();
     saveCommentToWiki( id, author, quote, text, ts, id );
      api.postWithEditToken({
    notifyByEmail( id, author, quote, text, ts );
        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();
    }
  }


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


   function saveCommentToWiki( id, author, quote, text, ts, anchorId ) {
   // ════════════════════════════════════════════════════════════════════
    if ( !window.mw ) return;
   // NOTE FLOW  (local only — localStorage, not wiki)
    var api = new mw.Api();
  // ════════════════════════════════════════════════════════════════════
    api.get({
 
      action:'query', prop:'revisions', titles:commentsPage,
  function openNoteComposer() {
      rvprop:'content', rvslots:'main', formatversion:2,
    hideFab();
    }).then(function(data){
    positionComposer( $ntComposer );
      var page = ((data.query && data.query.pages)||[])[0]||{};
    $ntComposer.addClass('gra-composer-visible');
      var existing = '';
    setTimeout( function() { $ntInput.focus(); }, isMobile() ? 300 : 0 );
      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    = ' + author + '\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:'Comment — ' + author,
        bot:0,
      }).then(function(){
        // Silently notify admin by appending a compact notice to their Talk page.
        // MediaWiki Echo picks this up and sends an email to whoever watches that page.
        // This is completely invisible to the commenter — no browser hijack.
        var adminTalk = 'User_talk:Chandrashekars';
        var artPath  = (mw.config.get('wgArticlePath')||'/wiki/$1');
        var link      = window.location.origin
                      + artPath.replace('$1', pageTitle)
                      + (anchorId ? '#' + anchorId : '');
        var notice = '\n<!-- grantha-comment-notify -->\n'
                  + '; [[' + pageTitle.replace(/_/g,' ') + ']] — ' + author + '\n'
                  + ': ' + quote.slice(0,80) + '\n'
                  + ': ' + link + '\n';
        new mw.Api().postWithEditToken({
          action:'edit', title:adminTalk, section:'new',
          sectiontitle:'New comment on ' + pageTitle.replace(/_/g,' '),
          text:notice,
          summary:'Comment notification',
          bot:1,  /* bot=1 suppresses Echo "someone edited your talk page" popup
                      but still triggers email watchlist notifications */
        }).catch(function(){});  /* silent — never block the comment flow */
      }).catch(function(){});
    }).catch(function(){});
   }
   }


   function notifyByEmail( anchorId, author, quote, text, ts ) {
   function closeNoteComposer() {
     /* Notification is handled server-side by MediaWiki's Echo extension
     $ntComposer.removeClass('gra-composer-visible');
    * when saveCommentToWiki() edits the Talk page — no client-side
    $ntInput.val('');
    * mailto needed. A mailto would abruptly open the user's mail app
    $ntSubmit.prop('disabled', true);
    * mid-session which breaks the commenting flow entirely. */
    _selRange = null; _selText = ''; _selRect = null;
   }
   }


  // ── Load + parse comments ────────────────────────────────────────────
   function submitNote() {
   function loadComments( cb ) {
     var text = $ntInput.val().trim();
     if ( _cmtLoaded ) { if (cb) cb(); return; }
     if ( !text ) return;
     if ( !window.mw ) { _cmtLoaded = true; if (cb) cb(); return; }
    var id    = uid();
     new mw.Api().get({
    var ts    = nowIso();
      action:'query', prop:'revisions', titles:commentsPage,
     var quote = _selText.slice(0,120) + (_selText.length > 120 ? '' : '');
      rvprop:'content', rvslots:'main', formatversion:2,
    var span  = wrapSelection( id, 'gra-note-highlight' );
     }).then(function(data){
     if ( span ) span.setAttribute('data-gra-quote', quote);
      var page = ((data.query && data.query.pages)||[])[0]||{};
    var entry = { id:id, ts:ts, quote:quote, text:text };
      var wt = '';
    _notes.push( entry );
      if (page.revisions && page.revisions[0]) {
    persistNotes();
        var rev = page.revisions[0];
    persistNoteHighlight( id, quote );
        wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
    renderNoteCards();
      }
    closeNoteComposer();
      _comments = parseCommentsWt(wt);
    openPanel('notes');
      _cmtLoaded = true;
  }
      if (cb) cb();
 
    }).catch(function(){ _cmtLoaded = true; if (cb) cb(); });
  function persistNotes() {
    try { localStorage.setItem( NT_LS_KEY, JSON.stringify(_notes) ); } catch(e){}
   }
   }


   function parseCommentsWt( wt ) {
   function loadNotes() {
     var out = [], re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g, m;
     try {
    while ((m = re.exec(wt)) !== null) {
       var r = localStorage.getItem( NT_LS_KEY );
       var block = m[1], f = {}, lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g, lm;
       if (r) _notes = JSON.parse(r) || [];
       while ((lm = lr.exec(block)) !== null) f[lm[1].trim()] = lm[2].trim();
    } catch(e){}
      if (f.id) out.push({ id:f.id, author:f.author||'अतिथि', ts:f.timestamp||'', quote:f.quote||'', text:f.text||'' });
    }
    return out;
   }
   }


Line 520: Line 527:
   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') loadComments(function(){ renderCommentCards(); });
     if (tab==='notes') renderNoteCards();
     else renderBookmarkCards();
     else renderBookmarkCards();
   }
   }
Line 532: Line 539:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function renderCommentCards() {
   function renderNoteCards() {
     if (!_comments.length) {
     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 click 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></div>'
            + '<button class="gra-note-del" data-del-id="' + esc(n.id) + '" title="Delete">×</button>'
             + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
             + '</div>'
             + '<div class="gra-card-text">' + esc(c.text) + '</div>'
             + (n.quote ? '<div class="gra-card-quote">' + esc(n.quote) + '</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 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();
   }
   }


Line 585: Line 611:


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // EVENT WIRING (desktop + mobile)
   // EVENT WIRING
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


Line 592: Line 618:
     // ── Desktop: mouseup / keyup ──────────────────────────────────
     // ── Desktop: mouseup / keyup ──────────────────────────────────
     $( document ).on('mouseup keyup', function(e){
     $( document ).on('mouseup keyup', function(e){
       if ( e.type === 'mouseup' && e.button !== 0 ) return; // left-click only
       if ( e.type === 'mouseup' && e.button !== 0 ) return;
       setTimeout( tryShowFab, 20 );
       setTimeout( tryShowFab, 20 );
     });
     });


     // ── Mobile: selectionchange (iOS + Android) ───────────────────
     // ── Mobile: selectionchange ───────────────────────────────────
    // 'selectionchange' fires continuously while user drags selection handles.
    // We debounce it and only act after the user has stopped for 400ms.
     var _selChangeTimer = null;
     var _selChangeTimer = null;
     document.addEventListener('selectionchange', function() {
     document.addEventListener('selectionchange', function() {
Line 605: Line 629:
       var v = _selVersion;
       var v = _selVersion;
       _selChangeTimer = setTimeout(function(){
       _selChangeTimer = setTimeout(function(){
        // Only trigger if this is still the latest selectionchange
         if ( v !== _selVersion ) return;
         if ( v !== _selVersion ) return;
         if ( _fabSelVer === v ) return; // already showed FAB for this selection
         if ( _fabSelVer === v ) return;
         tryShowFab();
         tryShowFab();
       }, 400);
       }, 400);
     });
     });


     // ── Mobile: touchend fallback (catch tap-to-end-selection) ────
     // ── Mobile: touchend fallback ─────────────────────────────────
     document.addEventListener('touchend', function(e){
     document.addEventListener('touchend', function(e){
      // Don't fire if tapping the FAB itself
       if ( $fab[0] && $fab[0].contains(e.target) ) return;
       if ( $fab[0] && $fab[0].contains(e.target) ) return;
       if ( $cmpComposer[0] && $cmpComposer[0].contains(e.target) ) return;
       if ( $fbComposer[0] && $fbComposer[0].contains(e.target) ) return;
       if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return;
      if ( $ntComposer[0] && $ntComposer[0].contains(e.target) ) return;
      // Small delay — browser settles selection after touchend
       if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return;
       setTimeout( tryShowFab, 80 );
       setTimeout( tryShowFab, 80 );
     }, { passive: true });
     }, { passive: true });
Line 626: Line 648:
       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 ( $cmpComposer[0] && $cmpComposer[0].contains(t) ) return;
       if ( $fbComposer[0] && $fbComposer[0].contains(t) ) return;
       if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return;
      if ( $ntComposer[0] && $ntComposer[0].contains(t) ) return;
       if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return;
       hideFab();
       hideFab();
     });
     });


     // ── FAB buttons ───────────────────────────────────────────────
     // ── FAB buttons ───────────────────────────────────────────────
     $( '#gra-fab-comment' ).on('click touchend', function(e){
     $( '#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();
       e.preventDefault(); e.stopPropagation();
      // Re-capture in case selection was cleared by the tap
       if ( !_selRange && !captureSelection() ) return;
       if ( !_selRange && !captureSelection() ) return;
       openCommentComposer();
       openNoteComposer();
     });
     });
     $( '#gra-fab-bookmark' ).on('click touchend', function(e){
     $( '#gra-fab-bookmark' ).on('click touchend', function(e){
Line 644: Line 671:
     });
     });


     // ── Comment composer ──────────────────────────────────────────
     // ── Feedback composer ─────────────────────────────────────────
     $cmpInput.on('input', function(){
     $fbIssueType.on('change', function(){
       $cmpSubmit.prop('disabled', !$( this ).val().trim());
       $fbSubmit.prop('disabled', !$( this ).val());
     });
     });
     $( '#gra-cmp-cancel' ).on('click', function(){ closeCommentComposer(); hideFab(); });
     $( '#gra-fb-cancel, #gra-fb-close' ).on('click', function(){
     $cmpSubmit.on('click', submitComment);
      closeFeedbackComposer(); hideFab();
     $cmpInput.on('keydown', function(e){
    });
       if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitComment();
    $fbSubmit.on('click', submitFeedback);
       if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
    $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(); }
     });
     });


Line 666: Line 705:
     $( '#gra-panel-close' ).on('click', closePanel);
     $( '#gra-panel-close' ).on('click', closePanel);
     $backdrop.on('click touchstart', closePanel);
     $backdrop.on('click touchstart', closePanel);
     $tabComments.on('click', function(){ switchTab('comments'); });
     $tabNotes.on('click', function(){ switchTab('notes'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });


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


     // Highlight in text → open panel
     // Click note highlight in text → open panel
     $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
     $( CONTENT_SEL ).on('click', '.gra-note-highlight', function(){
       var id = $( this ).attr('data-gra-id');
       var id = $( this ).attr('data-gra-id');
       openPanel('comments');
       openPanel('notes');
       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');
Line 712: Line 758:
     $( document ).on('keydown', function(e){
     $( document ).on('keydown', function(e){
       if (e.key !== 'Escape') return;
       if (e.key !== 'Escape') return;
       if ($cmpComposer.hasClass('gra-composer-visible')) { closeCommentComposer(); hideFab(); }
       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 if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); }
       else closePanel();
       else closePanel();
Line 722: Line 769:
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


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


   function restoreCommentHighlights() {
   function restoreNoteHighlights() {
     var s = [];
     var s = [];
     try { s = JSON.parse(localStorage.getItem(CMT_LS_KEY)||'[]'); } catch(e){}
     try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){}
     s.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;
Line 742: Line 789:
       if (range) {
       if (range) {
         var sp = document.createElement('span');
         var sp = document.createElement('span');
         sp.className = 'gra-comment-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 794: Line 841:
     buildDom();
     buildDom();
     wireEvents();
     wireEvents();
    loadNotes();
     loadBookmarks();
     loadBookmarks();
    restoreNoteHighlights();
     restoreBookmarkHighlights();
     restoreBookmarkHighlights();
    restoreCommentHighlights();
    loadComments(function(){});  // pre-load comments in background
   });
   });


}() );
}() );