MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 64: | Line 64: | ||
// ── Configuration ──────────────────────────────────────────────────── | // ── Configuration ──────────────────────────────────────────────────── | ||
var ADMIN_USER = 'GranthaGate'; | var ADMIN_USER = 'GranthaGate'; | ||
var CONTENT_SEL = '#mw-content-text'; | var CONTENT_SEL = '#mw-content-text'; | ||
var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
| 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>', | ||
' <textarea class="gra-composer-input" id="gra-cmp-input"', | ' <textarea class="gra-composer-input" id="gra-cmp-input"', | ||
' placeholder=" | ' placeholder="Comment or add others with @…" rows="3"></textarea>', | ||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>', | ' <button class="gra-btn-cancel" id="gra-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); | ||
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 = | 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 ); | ||
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'); | ||
$cmpInput.val('').focus(); | |||
} | } | ||
function closeCommentComposer() { | function closeCommentComposer() { | ||
$cmpComposer.removeClass('gra-composer-visible'); | $cmpComposer.removeClass('gra-composer-visible'); | ||
$cmpInput.val(''); | |||
$cmpInput | |||
$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 | /* Send a real email to the admin via MW's built-in EmailUser API. | ||
* | * This requires: | ||
* | * 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 = ' | var body = 'A new comment has been posted on ' + pageDisplay + '.\n\n' | ||
+ ' | + 'Posted by : ' + ( currentUser || 'Anonymous' ) + '\n' | ||
+ 'Time | + 'Time : ' + ts + '\n' | ||
+ 'Passage : "' + quote + '"\n\n' | + 'Passage : "' + quote + '"\n\n' | ||
+ 'Comment :\n' + commentText + '\n\n' | + 'Comment :\n' + commentText + '\n\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'; | |||
new mw.Api().post({ | new mw.Api().post({ | ||
action: | action: 'emailuser', | ||
target: | target: ADMIN_USER, | ||
subject: subject, | subject: subject, | ||
text: | text: body, | ||
token: | token: mw.user.tokens.get( 'csrfToken' ), | ||
}).catch(function(){ | }).catch(function(){ | ||
/* | /* EmailUser failed (e.g. admin has no email set) — fall back to | ||
* a talk-page notification so the admin still gets an Echo alert. */ | |||
if ( !currentUser ) return; | 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 ? '\ | + ( commentText.length > 500 ? '\n…' : '' ) | ||
+ '\n\n[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) | + '\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: | 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 ────────────────────────────────────── | ||
$( document ).on('mouseup keyup', function(e){ | |||
// Small delay so selection is committed | |||
setTimeout(function(){ | |||
if ( $cmpComposer.hasClass('gra-composer-visible') ) return; | |||
if ( $bmComposer.hasClass('gra-composer-visible') ) return; | |||
// | |||
setTimeout( function(){ | |||
if ( $cmpComposer.hasClass('gra-composer-visible') | |||
if ( captureSelection() ) { | if ( captureSelection() ) { | ||
showFab( _selRect ); | showFab( _selRect ); | ||
| Line 692: | Line 698: | ||
hideFab(); | hideFab(); | ||
} | } | ||
}, | }, 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 | // ── FAB: Comment ────────────────────────────────────────────── | ||
$( '#gra-fab-comment' ).on('click', function(e){ | |||
$( '#gra-fab-comment' ).on(' | |||
e.preventDefault(); | e.preventDefault(); | ||
e.stopPropagation(); | e.stopPropagation(); | ||
if ( !captureSelection() ) return; | |||
openCommentComposer(); | openCommentComposer(); | ||
}); | }); | ||
$( '#gra-fab-bookmark' ).on(' | // ── FAB: Bookmark ───────────────────────────────────────────── | ||
$( '#gra-fab-bookmark' ).on('click', function(e){ | |||
e.preventDefault(); | e.preventDefault(); | ||
e.stopPropagation(); | e.stopPropagation(); | ||
if ( !captureSelection() ) return; | |||
openBookmarkComposer(); | openBookmarkComposer(); | ||
}); | }); | ||
// ── Comment composer ────────────────────────────────────────── | // ── Comment composer ────────────────────────────────────────── | ||
$cmpInput.on('input', function(){ | $cmpInput.on('input', function(){ | ||
$cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser); | $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser); | ||