MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

No edit summary
No edit summary
Line 64: Line 64:


   // ── Configuration ────────────────────────────────────────────────────
   // ── Configuration ────────────────────────────────────────────────────
   var ADMIN_USER  = 'GranthaGate';   // MW username for talk-page fallback
   var ADMIN_USER  = 'GranthaGate';
  var ADMIN_EMAIL = 'admin@grantha.io'; // ← set your email here directly
   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 149: Line 148:
       '    <div class="gra-avatar" id="gra-cmp-avatar">' + esc(userInitial) + '</div>',
       '    <div class="gra-avatar" id="gra-cmp-avatar">' + esc(userInitial) + '</div>',
       '    <div class="gra-composer-name" id="gra-cmp-name">' + esc(currentUser || 'Anonymous') + '</div>',
       '    <div class="gra-composer-name" id="gra-cmp-name">' + esc(currentUser || 'Anonymous') + '</div>',
      '  </div>',
      '  <div class="gra-quick-chips" id="gra-quick-chips">',
      '    <button class="gra-chip" data-val="Spelling mistake" type="button">Spelling mistake</button>',
      '    <button class="gra-chip" data-val="Reference error" type="button">Reference error</button>',
      '    <button class="gra-chip" data-val="Others" type="button">Others ▾</button>',
       '  </div>',
       '  </div>',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
       '    placeholder="Describe the issue…" rows="3"',
       '    placeholder="Comment or add others with @…" rows="3"></textarea>',
      '    style="display:none"></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-cmp-cancel">Cancel</button>',
Line 164: Line 157:
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
 
    $( 'body' ).append( $cmpComposer );  // FIX 1: was missing, causing composer to never appear


     // ── Bookmark composer card ────────────────────────────────────────
     // ── Bookmark composer card ────────────────────────────────────────
Line 332: Line 325:
   function wrapSelection( id, cssClass ) {
   function wrapSelection( id, cssClass ) {
     if ( !_selRange ) return null;
     if ( !_selRange ) return null;
    // FIX 2: capture range into a local variable and immediately null out _selRange
    // so that surroundContents() mutating the Range object cannot corrupt future
    // selections — previously caused FAB to stop appearing after 2-3 highlights.
    var range = _selRange;
    _selRange = null;
     try {
     try {
       var span = document.createElement('span');
       var span = document.createElement('span');
       span.className = cssClass;
       span.className = cssClass;
       span.setAttribute('data-gra-id', id);
       span.setAttribute('data-gra-id', id);
       _selRange.surroundContents( span );
       range.surroundContents( span );
       return span;
       return span;
     } catch ( e ) {
     } catch ( e ) {
Line 342: Line 340:
       // extract and wrap the fragment
       // extract and wrap the fragment
       try {
       try {
         var frag = _selRange.extractContents();
         var frag = range.extractContents();
         var span2 = document.createElement('span');
         var span2 = document.createElement('span');
         span2.className = cssClass;
         span2.className = cssClass;
         span2.setAttribute('data-gra-id', id);
         span2.setAttribute('data-gra-id', id);
         span2.appendChild( frag );
         span2.appendChild( frag );
         _selRange.insertNode( span2 );
         range.insertNode( span2 );
         return span2;
         return span2;
       } catch(e2) { return null; }
       } catch(e2) { return null; }
Line 359: Line 357:
   function openCommentComposer() {
   function openCommentComposer() {
     hideFab();
     hideFab();
    // Keep selection alive — blur the textarea briefly then refocus
     positionComposer( $cmpComposer );
     positionComposer( $cmpComposer );
     $cmpComposer.addClass('gra-composer-visible');
     $cmpComposer.addClass('gra-composer-visible');
     // Don't focus the textarea — it's hidden until "Others" chip is selected.
     $cmpInput.val('').focus();
    // Focus the composer container so Escape key works.
    $cmpComposer[0].focus && $cmpComposer[0].setAttribute('tabindex', '-1');
   }
   }


   function closeCommentComposer() {
   function closeCommentComposer() {
     $cmpComposer.removeClass('gra-composer-visible');
     $cmpComposer.removeClass('gra-composer-visible');
    $( '#gra-quick-chips .gra-chip' ).removeClass('gra-chip-active');
     $cmpInput.val('');
     $cmpInput.hide().val('');
     $cmpSubmit.prop('disabled', true);
     $cmpSubmit.prop('disabled', true);
     _selRange = null;
     _selRange = null;
Line 395: Line 391:
     // Persist to wiki
     // Persist to wiki
     saveCommentToWiki( id, quote, text, ts );
     saveCommentToWiki( id, quote, text, ts );
     notifyAdmin( _activeId || '', quote, text, ts );
     // FIX 3: was `notifyAdmin( _activeId || '', … )` — _activeId is never declared,
    // causing a ReferenceError in strict mode that aborted submitComment() entirely.
    // Use `id` (the comment id created above) instead.
    notifyAdmin( id, quote, text, ts );


     // Update panel
     // Update panel
Line 433: Line 432:


   function notifyAdmin( anchorId, quote, commentText, ts ) {
   function notifyAdmin( anchorId, quote, commentText, ts ) {
     /* Send notification email directly using MW's EmailUser API, targeting
     /* Send a real email to the admin via MW's built-in EmailUser API.
     * ADMIN_EMAIL via the ADMIN_USER account — no server-side SMTP config needed.
     * This requires:
     * ADMIN_EMAIL is set at the top of this file; change it to any address. */
    *  1. The admin account (ADMIN_USER) has a confirmed email address set
     if ( !window.mw ) return;
    *      in Special:Preferences.
    *  2. $wgEnableEmail and $wgEnableUserEmail are true in LocalSettings.php
    *      (both are true by default in MW).
     * The email contains a direct scrollable anchor link to the passage. */
     if ( !window.mw || !ADMIN_USER ) return;


    /* Build a deep-link URL with the anchor ID so the admin can jump
    * directly to the highlighted passage with one click. */
     var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' )
     var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' )
                         .replace( '$1', pageTitle );
                         .replace( '$1', pageTitle );
     var anchorLink  = window.location.origin + articlePath
     var anchorLink  = window.location.origin + articlePath
                     + ( anchorId ? '#' + anchorId : '' );
                     + ( anchorId ? '#' + anchorId : '' );
     var pageDisplay = pageTitle.replace( /_/g, ' ' );
     var pageDisplay = pageTitle.replace( /_/g, ' ' );
     var subject    = '[Grantha] New comment on "' + pageDisplay + '"';
     var subject    = '[Grantha] New comment on "' + pageDisplay + '"';
     var body        = 'Page    : ' + pageDisplay + '\n'
     var body        = 'A new comment has been posted on ' + pageDisplay + '.\n\n'
                     + 'By      : ' + ( currentUser || 'Anonymous' ) + '\n'
                     + 'Posted by : ' + ( currentUser || 'Anonymous' ) + '\n'
                     + 'Time   : ' + ts + '\n'
                     + 'Time     : ' + ts + '\n'
                     + 'Passage : "' + quote + '"\n\n'
                     + 'Passage   : "' + quote + '"\n\n'
                     + 'Comment :\n' + commentText + '\n\n'
                     + 'Comment   :\n' + commentText + '\n\n'
                     + 'Link    : ' + anchorLink + '\n';
                     + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n'
                    + 'Jump to passage:\n' + anchorLink + '\n\n'
                    + 'View all comments:\n'
                    + window.location.origin
                    + ( mw.config.get('wgArticlePath') || '/wiki/$1' )
                        .replace( '$1', 'Talk:' + pageTitle + '/GrComments' )
                    + '\n';


    /* Primary: send via MW EmailUser API to ADMIN_USER account.
    * The admin account must have ADMIN_EMAIL set in Special:Preferences,
    * OR you can hardcode the target as any confirmed MW user. */
     new mw.Api().post({
     new mw.Api().post({
       action: 'emailuser',
       action:   'emailuser',
       target: ADMIN_USER,
       target:   ADMIN_USER,
       subject: subject,
       subject: subject,
       text:   body,
       text:     body,
       token:   mw.user.tokens.get( 'csrfToken' ),
       token:   mw.user.tokens.get( 'csrfToken' ),
     }).catch(function(){
     }).catch(function(){
       /* Fallback: post a section to admin talk page for Echo notification */
       /* EmailUser failed (e.g. admin has no email set) — fall back to
      * a talk-page notification so the admin still gets an Echo alert. */
       if ( !currentUser ) return;
       if ( !currentUser ) return;
      var adminTalk = 'User_talk:' + ADMIN_USER;
       var wikimsg = '== New comment on [[' + pageDisplay + ']] ==\n'
       var wikimsg = '== New comment on [[' + pageDisplay + ']] ==\n'
                   + '; By: ' + ( currentUser || 'Anonymous' ) + '\n'
                   + '; By: ' + ( currentUser || 'Anonymous' ) + '\n'
Line 468: Line 479:
                   + '; Link: ' + anchorLink + '\n\n'
                   + '; Link: ' + anchorLink + '\n\n'
                   + commentText.slice(0,500)
                   + commentText.slice(0,500)
                   + ( commentText.length > 500 ? '\n...' : '' )
                   + ( commentText.length > 500 ? '\n…' : '' )
                   + '\n\n[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 18:58, 25 April 2026 (UTC)';
                   + '\n\n[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 17:47, 25 April 2026 (UTC)';
       new mw.Api().postWithEditToken({
       new mw.Api().postWithEditToken({
         action:'edit', title:'User_talk:' + ADMIN_USER, section:'new',
         action:'edit', title:adminTalk, section:'new',
         sectiontitle:'Comment on ' + pageDisplay,
         sectiontitle:'Comment on ' + pageDisplay,
         text:wikimsg,
         text:wikimsg,
Line 677: Line 688:


     // ── Selection → show FAB ──────────────────────────────────────
     // ── Selection → show FAB ──────────────────────────────────────
     // Listen on the content area only, not the whole document,
     $( document ).on('mouseup keyup', function(e){
    // so we never accidentally capture selections in the panel/composer.
       // Small delay so selection is committed
    var $content = $( CONTENT_SEL );
       setTimeout(function(){
    if ( !$content.length ) $content = $( document );
         if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
 
        if ( $bmComposer.hasClass('gra-composer-visible') ) return;
    $content.on('mouseup', function(){
       // Brief delay so browser finalises the selection object
       setTimeout( function(){
         if ( $cmpComposer.hasClass('gra-composer-visible') ||
            $bmComposer.hasClass('gra-composer-visible') ) return;
         if ( captureSelection() ) {
         if ( captureSelection() ) {
           showFab( _selRect );
           showFab( _selRect );
Line 692: Line 698:
           hideFab();
           hideFab();
         }
         }
       }, 50 );
       }, 20);
    });
 
    // ── Click outside → hide FAB ──────────────────────────────────
    $( document ).on('mousedown', function(e){
      var $t = $(e.target);
      if ( !$t.closest('#gra-fab').length &&
          !$t.closest('.gra-composer').length &&
          !$t.closest('.gra-bm-composer').length ) {
        hideFab();
      }
     });
     });


     // ── FAB buttons ──────────────────────────────────────────────
     // ── FAB: Comment ──────────────────────────────────────────────
    // preventDefault on mousedown keeps the browser text-selection alive.
     $( '#gra-fab-comment' ).on('click', function(e){
    // We also stopPropagation so the document mousedown handler below
    // does NOT fire and hide the FAB before the click registers.
     $( '#gra-fab-comment' ).on('mousedown', function(e){
       e.preventDefault();
       e.preventDefault();
       e.stopPropagation();
       e.stopPropagation();
    }).on('click', function(){
      if ( !captureSelection() ) return;
       openCommentComposer();
       openCommentComposer();
     });
     });


     $( '#gra-fab-bookmark' ).on('mousedown', function(e){
    // ── FAB: Bookmark ─────────────────────────────────────────────
     $( '#gra-fab-bookmark' ).on('click', function(e){
       e.preventDefault();
       e.preventDefault();
       e.stopPropagation();
       e.stopPropagation();
    }).on('click', function(){
      if ( !captureSelection() ) return;
       openBookmarkComposer();
       openBookmarkComposer();
    });
    // ── Hide FAB when clicking elsewhere ─────────────────────────
    $( document ).on('mousedown', function(e){
      var $t = $( e.target );
      var inside = $t.closest(
        '#gra-fab, .gra-composer, .gra-bm-composer, #gra-toggle, #gra-panel'
      ).length;
      if ( !inside ) hideFab();
     });
     });


     // ── Comment composer ──────────────────────────────────────────
     // ── Comment composer ──────────────────────────────────────────
    /* Quick-select chips: clicking a chip selects it and sets comment text.
    * 'Others' chip shows the textarea for free-form input.
    * Any other chip sets the text directly and enables submit. */
    $( '#gra-cmp-composer' ).on( 'click', '.gra-chip', function() {
      var $chip = $( this );
      var val  = $chip.attr('data-val');
      // Toggle active state — allow deselecting
      var wasActive = $chip.hasClass('gra-chip-active');
      $( '#gra-quick-chips .gra-chip' ).removeClass('gra-chip-active');
      if ( !wasActive ) $chip.addClass('gra-chip-active');
      if ( val === 'Others' && !wasActive ) {
        // Show textarea for free-form input
        $cmpInput.show().focus().val('');
        $cmpSubmit.prop('disabled', true);
      } else if ( !wasActive ) {
        // Pre-fill and hide textarea — chip text is the comment
        $cmpInput.hide().val(val);
        $cmpSubmit.prop('disabled', !currentUser);
      } else {
        // Deselected — clear
        $cmpInput.hide().val('');
        $cmpSubmit.prop('disabled', true);
      }
    } );
     $cmpInput.on('input', function(){
     $cmpInput.on('input', function(){
       $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);
       $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);