MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 30: | Line 30: | ||
* STORAGE | * STORAGE | ||
* ─────── | * ─────── | ||
* Comments → MediaWiki page: <PageTitle>/ | * Comments → MediaWiki page: Talk:<PageTitle>/GrComments | ||
* Format: {{GrComment|id | * Talk namespace is excluded from XML/PDF exports and never | ||
* shown in the content editor — comments cannot affect exports. | |||
* Bookmarks → localStorage | * Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}} | ||
* | * Bookmarks → localStorage (per-user, per-page — not shared across users) | ||
* Key: grantha_bm_<pageName> | |||
* | * | ||
* ADMIN NOTIFICATION | * ADMIN NOTIFICATION | ||
* ────────────────── | * ────────────────── | ||
* On new comment: | * On new comment: sends email via MW EmailUser API (action=emailuser). | ||
* | * Email contains the comment text and a direct anchor link to the passage. | ||
* Fallback: if admin has no email set, posts to User_talk:ADMIN_USER instead. | |||
* Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults). | |||
* | * | ||
* DEPENDENCIES | * DEPENDENCIES | ||
| Line 66: | Line 69: | ||
var CMT_LS_KEY = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | var CMT_LS_KEY = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | ||
var commentsPage = pageTitle + '/ | /* Comments stored on Talk:<PageTitle>/GrComments — talk namespace is | ||
* excluded from XML/PDF exports and never shown in the content editor, | |||
* so comments cannot tamper with document content or affect exports. */ | |||
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() : '?'; | ||
| Line 380: | Line 386: | ||
// Persist to wiki | // Persist to wiki | ||
saveCommentToWiki( id, quote, text, ts ); | saveCommentToWiki( id, quote, text, ts ); | ||
notifyAdmin( quote, text, ts ); | notifyAdmin( _activeId || '', quote, text, ts ); | ||
// Update panel | // Update panel | ||
| Line 411: | Line 417: | ||
api.postWithEditToken({ | api.postWithEditToken({ | ||
action:'edit', title:commentsPage, text:updated, | action:'edit', title:commentsPage, text:updated, | ||
summary:'New comment by ' + currentUser + ' on [[' + pageTitle + ']]', | summary:'New comment by ' + currentUser + ' on [[' + pageTitle + ']] (stored off-page)', | ||
bot:0, | bot:0, | ||
}).catch(function(){}); | }).catch(function(){}); | ||
| Line 417: | Line 423: | ||
} | } | ||
function notifyAdmin( quote, commentText, ts ) { | function notifyAdmin( anchorId, quote, commentText, ts ) { | ||
if ( !window.mw || !ADMIN_USER || !currentUser ) return; | /* 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 | |||
* 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' ) | |||
.replace( '$1', pageTitle ); | |||
var anchorLink = window.location.origin + articlePath | |||
+ ( anchorId ? '#' + anchorId : '' ); | |||
var pageDisplay = pageTitle.replace( /_/g, ' ' ); | |||
var subject = '[Grantha] New comment on "' + pageDisplay + '"'; | |||
var body = 'A new comment has been posted on ' + pageDisplay + '. | |||
' | |||
+ 'Posted by : ' + ( currentUser || 'Anonymous' ) + ' | |||
' | |||
+ 'Time : ' + ts + ' | |||
' | |||
+ 'Passage : "' + quote + '" | |||
' | |||
+ 'Comment : | |||
' + commentText + ' | |||
' | |||
+ '────────────────────────────────── | |||
' | |||
+ 'Jump to passage: | |||
' + anchorLink + ' | |||
' | |||
+ 'View all comments: | |||
' | |||
+ window.location.origin | |||
+ ( mw.config.get('wgArticlePath') || '/wiki/$1' ) | |||
.replace( '$1', 'Talk:' + pageTitle + '/GrComments' ) | |||
+ ' | |||
'; | |||
new mw.Api().post({ | |||
action: 'emailuser', | |||
target: ADMIN_USER, | |||
subject: subject, | |||
text: body, | |||
token: mw.user.tokens.get( 'csrfToken' ), | |||
}).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; | |||
var adminTalk = 'User_talk:' + ADMIN_USER; | |||
var wikimsg = '== New comment on [[' + pageDisplay + ']] == | |||
' | |||
+ '; By: ' + ( currentUser || 'Anonymous' ) + ' | |||
' | |||
+ '; Passage: //' + quote + '// | |||
' | |||
+ '; Link: ' + anchorLink + ' | |||
' | |||
+ commentText.slice(0,500) | |||
+ ( commentText.length > 500 ? ' | |||
…' : '' ) | |||
+ ' | |||
[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 10:47, 24 April 2026 (UTC)'; | |||
new mw.Api().postWithEditToken({ | |||
action:'edit', title:adminTalk, section:'new', | |||
sectiontitle:'Comment on ' + pageDisplay, | |||
text:wikimsg, | |||
summary:'Comment notification from [[' + pageDisplay + ']]', | |||
bot:0, | |||
}).catch(function(){}); | |||
}); | |||
} | } | ||
Revision as of 10:47, 24 April 2026
/**
* gr_annotations.js — grantha.io inline Comments + Bookmarks
* ══════════════════════════════════════════════════════════════════════
*
* BEHAVIOUR (mirrors Google Docs)
* ────────────────────────────────
* 1. User selects text anywhere in mw-content-text.
* 2. A floating action strip (FAB) appears to the right of the selection
* with two icon buttons: Comment and Bookmark.
* 3. Clicking Comment:
* - Opens an inline composer card anchored to the selection position.
* - On submit: wraps the selected text in a yellow highlight span,
* saves the comment to <PageTitle>/Comments via MW edit API,
* posts a notification to the admin user talk page,
* and adds a card to the Comments tab of the right panel.
* 4. Clicking Bookmark:
* - Opens a small composer to name the bookmark.
* - On submit: wraps selected text in a blue highlight span,
* saves bookmark in localStorage (per-user, per-page),
* and adds a card to the Bookmarks tab.
* - Clicking a bookmark card scrolls to and flashes that highlight.
* 5. Right panel:
* - Slides in from the right as an overlay (no layout shift).
* - Tab 1: Comments (loads from /Comments wiki page)
* - Tab 2: Bookmarks (loads from localStorage)
* - Panel can be closed via ✕ or clicking the backdrop.
* 6. Clicking any comment/bookmark highlight in the text:
* - Opens the panel to the correct tab and scrolls to the card.
*
* STORAGE
* ───────
* Comments → MediaWiki page: Talk:<PageTitle>/GrComments
* Talk namespace is excluded from XML/PDF exports and never
* shown in the content editor — comments cannot affect exports.
* Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}}
* Bookmarks → localStorage (per-user, per-page — not shared across users)
* Key: grantha_bm_<pageName>
*
* ADMIN NOTIFICATION
* ──────────────────
* On new comment: sends email via MW EmailUser API (action=emailuser).
* Email contains the comment text and a direct anchor link to the passage.
* Fallback: if admin has no email set, posts to User_talk:ADMIN_USER instead.
* Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults).
*
* DEPENDENCIES
* ────────────
* • jQuery (loaded by MediaWiki)
* • mw.Api (MediaWiki core)
* • gr_annotations.css (companion stylesheet)
* • /images/commentary.svg, /images/bookmark.svg (Hostinger)
*
* DEPLOY
* ──────
* Add both files to MediaWiki:Common.css / Common.js, or register as
* a gadget in MediaWiki:Gadgets-definition:
* GrAnnotations[ResourceLoader|default]|gr_annotations.js|gr_annotations.css
* ══════════════════════════════════════════════════════════════════════
*/
/* global mw, $ */
( function () {
'use strict';
// ── Configuration ────────────────────────────────────────────────────
var ADMIN_USER = 'GranthaGate';
var CONTENT_SEL = '#mw-content-text';
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 pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
/* Comments stored on Talk:<PageTitle>/GrComments — talk namespace is
* excluded from XML/PDF exports and never shown in the content editor,
* so comments cannot tamper with document content or affect exports. */
var commentsPage = 'Talk:' + pageTitle + '/GrComments';
var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
// Only run on content namespaces, not on the /Comments page itself
if ( window.mw ) {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns < 0 ) return;
if ( /\/Comments$/.test( pageTitle ) ) return;
}
// ── State ────────────────────────────────────────────────────────────
var _sel = null; // current Selection snapshot
var _selRange = null; // saved Range for wrapping
var _selText = ''; // selected text string
var _selRect = null; // bounding rect of selection
var _comments = []; // [{id, anchor, author, ts, quote, text}]
var _bookmarks = []; // [{id, name, quote, anchorHtml, ts}]
var _cmtLoaded = false;
var _activeTab = 'comments'; // 'comments' | 'bookmarks'
// ── Helpers ──────────────────────────────────────────────────────────
function uid() {
return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7);
}
function esc( s ) {
return String( s || '' )
.replace(/&/g,'&').replace(/</g,'<')
.replace(/>/g,'>').replace(/"/g,'"');
}
function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/,'Z');
}
function fmtTs( ts ) {
try {
var d = new Date( ts );
return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})
+ ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false});
} catch(e){ return ts; }
}
function clamp( val, min, max ) { return Math.max(min, Math.min(max, val)); }
// ── DOM references (populated in boot) ───────────────────────────────
var $fab, $panel, $backdrop, $panelBody;
var $cmpComposer, $cmpInput, $cmpSubmit;
var $bmComposer, $bmInput, $bmSubmit;
var $tabComments, $tabBookmarks;
var $paneComments, $paneBookmarks;
// ════════════════════════════════════════════════════════════════════
// DOM BUILDER
// ════════════════════════════════════════════════════════════════════
function buildDom() {
// ── Floating action strip ────────────────────────────────────────
$fab = $( [
'<div id="gra-fab">',
' <button class="gra-fab-btn" id="gra-fab-comment" type="button">',
' <span class="gra-icon gra-icon-comment"></span>',
' <span class="gra-fab-tooltip">Add comment</span>',
' </button>',
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button">',
' <span class="gra-icon gra-icon-bookmark"></span>',
' <span class="gra-fab-tooltip">Bookmark</span>',
' </button>',
'</div>',
].join('') );
$( 'body' ).append( $fab );
// ── Comment composer card ────────────────────────────────────────
$cmpComposer = $( [
'<div class="gra-composer" id="gra-cmp-composer">',
' <div class="gra-composer-user">',
' <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>',
' <textarea class="gra-composer-input" id="gra-cmp-input"',
' placeholder="Comment or add others with @…" rows="3"></textarea>',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>',
' <button class="gra-btn-submit" id="gra-cmp-submit" disabled>Comment</button>',
' </div>',
'</div>',
].join('') );
$( 'body' ).append( $cmpComposer );
// ── Bookmark composer card ────────────────────────────────────────
$bmComposer = $( [
'<div class="gra-bm-composer" id="gra-bm-composer">',
' <div class="gra-bm-composer-label">',
' <span class="gra-icon gra-icon-bookmark"></span>',
' Save bookmark',
' </div>',
' <input class="gra-composer-input" id="gra-bm-input"',
' type="text" placeholder="Bookmark name…" autocomplete="off">',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
' <button class="gra-btn-submit" id="gra-bm-submit">Save</button>',
' </div>',
'</div>',
].join('') );
$( 'body' ).append( $bmComposer );
// ── Right panel ───────────────────────────────────────────────────
$panel = $( [
'<div id="gra-panel">',
' <div id="gra-panel-head">',
' <div></div>',
' <button id="gra-panel-close" title="Close panel">✕</button>',
' </div>',
' <div id="gra-tabs">',
' <button class="gra-tab gra-tab-active" id="gra-tab-comments">',
' <span class="gra-icon gra-icon-comment"></span> Comments',
' </button>',
' <button class="gra-tab" id="gra-tab-bookmarks">',
' <span class="gra-icon gra-icon-bookmark"></span> Bookmarks',
' </button>',
' </div>',
' <div id="gra-panel-body">',
' <div class="gra-pane gra-pane-active" id="gra-pane-comments"></div>',
' <div class="gra-pane" id="gra-pane-bookmarks"></div>',
' </div>',
'</div>',
].join('') );
$( 'body' ).append( $panel );
// Backdrop
$backdrop = $('<div id="gra-backdrop"></div>');
$( 'body' ).append( $backdrop );
// Persistent toggle button — always visible, opens the panel
var $toggle = $( [
'<button id="gra-toggle" title="Comments & Bookmarks">',
' <span class="gra-icon gra-icon-comment" id="gra-toggle-icon"></span>',
' <span id="gra-toggle-badge"></span>',
'</button>',
].join('') );
$( 'body' ).append( $toggle );
$toggle.on( 'click', function() {
if ( $panel.hasClass('gra-panel-open') ) {
closePanel();
} else {
openPanel( _activeTab );
}
} );
// Cache references
$panelBody = $( '#gra-panel-body' );
// Set panel title to page name
$( '#gra-panel-head div' ).first()
.text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) )
.css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' });
$tabComments = $( '#gra-tab-comments' );
$tabBookmarks = $( '#gra-tab-bookmarks' );
$paneComments = $( '#gra-pane-comments' );
$paneBookmarks = $( '#gra-pane-bookmarks' );
$cmpInput = $( '#gra-cmp-input' );
$cmpSubmit = $( '#gra-cmp-submit' );
$bmInput = $( '#gra-bm-input' );
$bmSubmit = $( '#gra-bm-submit' );
// Disable comment submit when not logged in
if ( !currentUser ) {
$cmpSubmit.prop( 'disabled', true );
$cmpComposer.find( '.gra-composer-actions' )
.prepend( '<a href="' + esc( mw ? mw.util.getUrl('Special:UserLogin') : '/wiki/Special:UserLogin' ) +
'" style="font-size:11px;color:#999;margin-right:auto">Log in to comment</a>' );
}
}
// ════════════════════════════════════════════════════════════════════
// FLOATING ACTION STRIP — position & show/hide
// ════════════════════════════════════════════════════════════════════
function showFab( rect ) {
if ( !rect ) return;
// FAB uses position:fixed — coords are viewport-relative (no scroll offset needed).
// Place strip to the right of the selection; if it would go off-screen, place to the left.
var fabW = 46; var fabH = 84;
var top = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
var left = rect.right + 10;
// If too close to right edge, flip to left of selection
if ( left + fabW > window.innerWidth - 8 ) {
left = rect.left - fabW - 10;
}
// Clamp vertically within viewport
top = clamp( top, 8, window.innerHeight - fabH - 8 );
// Clamp horizontally
left = clamp( left, 8, window.innerWidth - fabW - 8 );
$fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
}
function hideFab() {
$fab.removeClass('gra-fab-visible');
}
// ════════════════════════════════════════════════════════════════════
// SELECTION HANDLING
// ════════════════════════════════════════════════════════════════════
function captureSelection() {
var sel = window.getSelection();
if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
var range = sel.getRangeAt(0);
var text = sel.toString().trim();
if ( !text || text.length < 2 ) return false;
// Must be inside mw-content-text
var contentEl = document.querySelector( CONTENT_SEL );
if ( !contentEl ) return false;
var startEl = range.commonAncestorContainer;
if ( startEl.nodeType === 3 ) startEl = startEl.parentNode;
if ( !contentEl.contains( startEl ) ) return false;
_selText = text;
_selRange = range.cloneRange();
_selRect = range.getBoundingClientRect();
return true;
}
// ════════════════════════════════════════════════════════════════════
// COMPOSER POSITIONING
// ════════════════════════════════════════════════════════════════════
function positionComposer( $el ) {
if ( !_selRect ) return;
// position:fixed — viewport coords only
var top = _selRect.bottom + 8;
var left = _selRect.left;
// Keep composer within viewport
var composerW = 308;
if ( left + composerW > window.innerWidth - 8 ) {
left = window.innerWidth - composerW - 8;
}
left = Math.max( left, 8 );
// If composer would appear below viewport, show above selection instead
if ( top + 160 > window.innerHeight ) {
top = _selRect.top - 170;
}
top = Math.max( top, 8 );
$el.css({ top: top + 'px', left: left + 'px' });
}
// ════════════════════════════════════════════════════════════════════
// WRAP SELECTION IN HIGHLIGHT SPAN
// ════════════════════════════════════════════════════════════════════
function wrapSelection( id, cssClass ) {
if ( !_selRange ) return null;
try {
var span = document.createElement('span');
span.className = cssClass;
span.setAttribute('data-gra-id', id);
_selRange.surroundContents( span );
return span;
} catch ( e ) {
// surroundContents fails for multi-element selections;
// extract and wrap the fragment
try {
var frag = _selRange.extractContents();
var span2 = document.createElement('span');
span2.className = cssClass;
span2.setAttribute('data-gra-id', id);
span2.appendChild( frag );
_selRange.insertNode( span2 );
return span2;
} catch(e2) { return null; }
}
}
// ════════════════════════════════════════════════════════════════════
// COMMENT FLOW
// ════════════════════════════════════════════════════════════════════
function openCommentComposer() {
hideFab();
// Keep selection alive — blur the textarea briefly then refocus
positionComposer( $cmpComposer );
$cmpComposer.addClass('gra-composer-visible');
$cmpInput.val('').focus();
}
function closeCommentComposer() {
$cmpComposer.removeClass('gra-composer-visible');
$cmpInput.val('');
$cmpSubmit.prop('disabled', true);
_selRange = null;
_selText = '';
_selRect = null;
}
function submitComment() {
var text = $cmpInput.val().trim();
if ( !text || !currentUser ) return;
var id = uid();
var ts = nowIso();
var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' );
// Wrap text in highlight span
var span = wrapSelection( id, 'gra-comment-highlight' );
if ( span ) span.setAttribute('data-gra-quote', quote);
// Save locally
var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text };
_comments.push( entry );
// Persist highlight anchor so it survives page refresh
persistCommentHighlight( id, quote );
// Persist to wiki
saveCommentToWiki( id, quote, text, ts );
notifyAdmin( _activeId || '', quote, text, ts );
// Update panel
renderCommentCards();
closeCommentComposer();
openPanel('comments');
}
function saveCommentToWiki( id, quote, text, ts ) {
if ( !window.mw ) return;
var api = new mw.Api();
api.get({
action:'query', prop:'revisions', titles:commentsPage,
rvprop:'content', rvslots:'main', formatversion:2,
}).then(function(data){
var page = (data.query.pages||[])[0]||{};
var existing = '';
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 = ' + currentUser + '\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:'New comment by ' + currentUser + ' on [[' + pageTitle + ']] (stored off-page)',
bot:0,
}).catch(function(){});
}).catch(function(){});
}
function notifyAdmin( anchorId, quote, commentText, ts ) {
/* 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
* 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' )
.replace( '$1', pageTitle );
var anchorLink = window.location.origin + articlePath
+ ( anchorId ? '#' + anchorId : '' );
var pageDisplay = pageTitle.replace( /_/g, ' ' );
var subject = '[Grantha] New comment on "' + pageDisplay + '"';
var body = 'A new comment has been posted on ' + pageDisplay + '.
'
+ 'Posted by : ' + ( currentUser || 'Anonymous' ) + '
'
+ 'Time : ' + ts + '
'
+ 'Passage : "' + quote + '"
'
+ 'Comment :
' + commentText + '
'
+ '──────────────────────────────────
'
+ 'Jump to passage:
' + anchorLink + '
'
+ 'View all comments:
'
+ window.location.origin
+ ( mw.config.get('wgArticlePath') || '/wiki/$1' )
.replace( '$1', 'Talk:' + pageTitle + '/GrComments' )
+ '
';
new mw.Api().post({
action: 'emailuser',
target: ADMIN_USER,
subject: subject,
text: body,
token: mw.user.tokens.get( 'csrfToken' ),
}).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;
var adminTalk = 'User_talk:' + ADMIN_USER;
var wikimsg = '== New comment on [[' + pageDisplay + ']] ==
'
+ '; By: ' + ( currentUser || 'Anonymous' ) + '
'
+ '; Passage: //' + quote + '//
'
+ '; Link: ' + anchorLink + '
'
+ commentText.slice(0,500)
+ ( commentText.length > 500 ? '
…' : '' )
+ '
[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 10:47, 24 April 2026 (UTC)';
new mw.Api().postWithEditToken({
action:'edit', title:adminTalk, section:'new',
sectiontitle:'Comment on ' + pageDisplay,
text:wikimsg,
summary:'Comment notification from [[' + pageDisplay + ']]',
bot:0,
}).catch(function(){});
});
}
// ── Load comments from /Comments wiki page ───────────────────────
function loadComments( cb ) {
if ( _cmtLoaded ) { if (cb) cb(); return; }
if ( !window.mw ) { _cmtLoaded=true; if(cb)cb(); return; }
new mw.Api().get({
action:'query', prop:'revisions', titles:commentsPage,
rvprop:'content', rvslots:'main', formatversion:2,
}).then(function(data){
var page = (data.query.pages||[])[0]||{};
var wt = '';
if (page.revisions && page.revisions[0]) {
var rev = page.revisions[0];
wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
}
_comments = parseCommentsWt(wt);
_cmtLoaded = true;
if (cb) cb();
}).catch(function(){ _cmtLoaded=true; if(cb)cb(); });
}
function parseCommentsWt( wt ) {
var out = [];
var re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g;
var m;
while ((m = re.exec(wt)) !== null) {
var block = m[1];
var f = {};
var lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g;
var lm;
while ((lm = lr.exec(block)) !== null) {
f[lm[1].trim()] = lm[2].trim();
}
if (f.id) out.push({
id:f.id, author:f.author||'Anonymous',
ts:f.timestamp||'', quote:f.quote||'', text:f.text||''
});
}
return out;
}
// ════════════════════════════════════════════════════════════════════
// BOOKMARK FLOW
// ════════════════════════════════════════════════════════════════════
function openBookmarkComposer() {
hideFab();
positionComposer( $bmComposer );
$bmComposer.addClass('gra-composer-visible');
$bmInput.val('').focus();
}
function closeBookmarkComposer() {
$bmComposer.removeClass('gra-composer-visible');
$bmInput.val('');
_selRange = null;
_selText = '';
_selRect = null;
}
function submitBookmark() {
var name = $bmInput.val().trim() || ( 'Bookmark ' + (_bookmarks.length+1) );
var id = uid();
var ts = nowIso();
var quote = _selText.slice(0,120) + (_selText.length>120?'…':'');
// Wrap text in bookmark highlight span
var span = wrapSelection( id, 'gra-bookmark-highlight' );
if ( span ) {
span.setAttribute('data-gra-id', id);
span.setAttribute('data-gra-name', name);
}
var entry = { id:id, name:name, quote:quote, ts:ts };
_bookmarks.push( entry );
persistBookmarks();
renderBookmarkCards();
closeBookmarkComposer();
openPanel('bookmarks');
}
function deleteBookmark( id ) {
_bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
// Remove highlight span, replace with its text content
var span = document.querySelector('[data-gra-id="' + id + '"].gra-bookmark-highlight');
if (span) {
var parent = span.parentNode;
while (span.firstChild) parent.insertBefore(span.firstChild, span);
parent.removeChild(span);
}
persistBookmarks();
renderBookmarkCards();
}
function persistBookmarks() {
try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){}
}
function loadBookmarks() {
try {
var raw = localStorage.getItem(BM_LS_KEY);
if (raw) _bookmarks = JSON.parse(raw) || [];
} catch(e){ _bookmarks = []; }
}
// ════════════════════════════════════════════════════════════════════
// PANEL — open / close / tabs
// ════════════════════════════════════════════════════════════════════
function openPanel( tab ) {
_activeTab = tab || _activeTab;
switchTab( _activeTab );
$panel.addClass('gra-panel-open');
$backdrop.addClass('gra-backdrop-visible');
}
function closePanel() {
$panel.removeClass('gra-panel-open');
$backdrop.removeClass('gra-backdrop-visible');
}
function switchTab( tab ) {
_activeTab = tab;
$tabComments.toggleClass('gra-tab-active', tab==='comments');
$tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
$paneComments.toggleClass('gra-pane-active', tab==='comments');
$paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
if (tab==='comments') {
loadComments(function(){ renderCommentCards(); });
} else {
renderBookmarkCards();
}
}
// ════════════════════════════════════════════════════════════════════
// RENDER CARDS
// ════════════════════════════════════════════════════════════════════
function renderCommentCards() {
if ( _comments.length === 0 ) {
$paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>');
return;
}
var html = '';
_comments.slice().reverse().forEach(function(c){
html += '<div class="gra-comment-card" data-gra-id="' + esc(c.id) + '">'
+ '<div class="gra-card-header">'
+ '<div class="gra-avatar">' + esc((c.author||'?').charAt(0).toUpperCase()) + '</div>'
+ '<div class="gra-card-meta">'
+ '<div class="gra-card-author">' + esc(c.author) + '</div>'
+ (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
+ '</div>'
+ '</div>'
+ (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
+ '<div class="gra-card-text">' + esc(c.text) + '</div>'
+ '</div>';
});
$paneComments.html(html);
}
function renderBookmarkCards() {
if ( _bookmarks.length === 0 ) {
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save a bookmark.</div>');
return;
}
var html = '';
_bookmarks.slice().reverse().forEach(function(b){
html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">'
+ '<span class="gra-icon gra-icon-bookmark"></span>'
+ '<div class="gra-bookmark-info">'
+ '<div class="gra-bookmark-name">' + esc(b.name) + '</div>'
+ (b.quote ? '<div class="gra-bookmark-quote">' + esc(b.quote) + '</div>' : '')
+ '</div>'
+ '<button class="gra-bookmark-del" data-del-id="' + esc(b.id) + '" title="Remove bookmark">×</button>'
+ '</div>';
});
$paneBookmarks.html(html);
}
// ════════════════════════════════════════════════════════════════════
// SCROLL TO HIGHLIGHT
// ════════════════════════════════════════════════════════════════════
function scrollToHighlight( id ) {
var el = document.querySelector('[data-gra-id="' + id + '"]');
if (!el) return;
el.scrollIntoView({ behavior:'smooth', block:'center' });
el.classList.add('gra-hl-active');
setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
}
// ════════════════════════════════════════════════════════════════════
// EVENT WIRING
// ════════════════════════════════════════════════════════════════════
function wireEvents() {
// ── 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;
if ( captureSelection() ) {
showFab( _selRect );
} else {
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: Comment ──────────────────────────────────────────────
$( '#gra-fab-comment' ).on('click', function(e){
e.preventDefault();
e.stopPropagation();
if ( !captureSelection() ) return;
openCommentComposer();
});
// ── FAB: Bookmark ─────────────────────────────────────────────
$( '#gra-fab-bookmark' ).on('click', function(e){
e.preventDefault();
e.stopPropagation();
if ( !captureSelection() ) return;
openBookmarkComposer();
});
// ── Comment composer ──────────────────────────────────────────
$cmpInput.on('input', function(){
$cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);
});
$( '#gra-cmp-cancel' ).on('click', function(){
closeCommentComposer();
hideFab();
});
$cmpSubmit.on('click', submitComment);
$cmpInput.on('keydown', function(e){
// Ctrl+Enter or Cmd+Enter submits
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); }
if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
});
// ── Bookmark composer ─────────────────────────────────────────
$( '#gra-bm-cancel' ).on('click', function(){
closeBookmarkComposer();
hideFab();
});
$bmSubmit.on('click', submitBookmark);
$bmInput.on('keydown', function(e){
if (e.key==='Enter') submitBookmark();
if (e.key==='Escape'){ closeBookmarkComposer(); hideFab(); }
});
// ── Panel close ───────────────────────────────────────────────
$( '#gra-panel-close' ).on('click', closePanel);
$backdrop.on('click', closePanel);
// ── Tab switching ─────────────────────────────────────────────
$tabComments.on('click', function(){ switchTab('comments'); });
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
// ── Panel: click comment card → scroll to highlight ───────────
$paneComments.on('click', '.gra-comment-card', function(){
var id = $(this).attr('data-gra-id');
if (id) scrollToHighlight(id);
});
// ── Panel: click bookmark card → scroll to highlight ──────────
$paneBookmarks.on('click', '.gra-bookmark-card', function(e){
if ($(e.target).hasClass('gra-bookmark-del')) return;
var id = $(this).attr('data-gra-id');
if (id) scrollToHighlight(id);
});
// ── Panel: delete bookmark ────────────────────────────────────
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){
e.stopPropagation();
var id = $(this).attr('data-del-id');
if (id) deleteBookmark(id);
});
// ── Click on highlight in text → open panel ───────────────────
$( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
var id = $(this).attr('data-gra-id');
openPanel('comments');
// After render, scroll to matching card
setTimeout(function(){
var $card = $paneComments.find('[data-gra-id="'+id+'"]');
if ($card.length) {
$card.addClass('gra-card-active');
$card[0].scrollIntoView({behavior:'smooth',block:'nearest'});
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
}
}, 100);
});
$( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){
var id = $(this).attr('data-gra-id');
openPanel('bookmarks');
setTimeout(function(){
var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
if ($card.length) $card[0].scrollIntoView({behavior:'smooth',block:'nearest'});
}, 100);
});
// ── Escape key closes panels / composers ─────────────────────
$( document ).on('keydown', function(e){
if (e.key==='Escape'){
if ($cmpComposer.hasClass('gra-composer-visible')) { closeCommentComposer(); hideFab(); }
else if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); }
else closePanel();
}
});
}
// ════════════════════════════════════════════════════════════════════
// RESTORE BOOKMARK HIGHLIGHTS from localStorage on page load
// ════════════════════════════════════════════════════════════════════
// Bookmarks store the quote text. We do a best-effort text search
// in the content to re-wrap the same span after page reload.
// (Comments are server-stored and we only re-render cards, not re-wrap.)
// ── Persist comment highlight anchors to localStorage ────────────────
// We only store {id, quote} — the full comment data lives on the wiki.
// On reload, restoreCommentHighlights re-wraps the quote text in the page
// so the yellow highlight appears again.
function persistCommentHighlight( id, quote ) {
try {
var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
// Deduplicate
stored = stored.filter( function(h){ return h.id !== id; } );
stored.push( { id: id, quote: quote } );
localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
} catch(e){}
}
function restoreCommentHighlights() {
var stored = [];
try { stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' ); } catch(e){}
stored.forEach( function(h) {
if ( !h.quote || !h.id ) return;
if ( document.querySelector( '[data-gra-id="' + h.id + '"].gra-comment-highlight' ) ) return;
var needle = h.quote.replace(/…$/, '').trim().slice(0, 80);
if ( !needle ) return;
var range = findTextInContent( document.querySelector(CONTENT_SEL), needle );
if ( range ) {
var span = document.createElement('span');
span.className = 'gra-comment-highlight';
span.setAttribute('data-gra-id', h.id);
try { range.surroundContents(span); } catch(e){}
}
});
}
function restoreBookmarkHighlights() {
_bookmarks.forEach(function(b){
if ( !b.quote ) return;
// Already highlighted from this session
if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return;
// Text search — find first occurrence of quote in content
var contentEl = document.querySelector( CONTENT_SEL );
if (!contentEl) return;
var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
if (!needle) return;
// Walk text nodes to find a match
var found = findTextInContent( contentEl, needle );
if (found) {
var span = document.createElement('span');
span.className = 'gra-bookmark-highlight';
span.setAttribute('data-gra-id', b.id);
span.setAttribute('data-gra-name', b.name);
try {
found.surroundContents(span);
} catch(e){}
}
});
}
function findTextInContent( root, needle ) {
// Returns a Range covering the first occurrence of needle in root's text
var text = root.textContent || '';
var idx = text.indexOf(needle);
if (idx < 0) return null;
// Walk to find the exact text nodes
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
var pos = 0;
var node, startNode, startOffset, endNode, endOffset;
while ((node = iter.nextNode())) {
var len = node.nodeValue.length;
if (!startNode && pos+len > idx) {
startNode = node;
startOffset = idx - pos;
}
var endIdx = idx + needle.length;
if (startNode && pos+len >= endIdx) {
endNode = node;
endOffset = endIdx - pos;
break;
}
pos += len;
}
if (!startNode || !endNode) return null;
var range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}
// ════════════════════════════════════════════════════════════════════
// BOOT
// ════════════════════════════════════════════════════════════════════
$( function () {
buildDom();
wireEvents();
loadBookmarks();
restoreBookmarkHighlights();
restoreCommentHighlights();
// Pre-load comment count in background
loadComments(function(){
// Update tab label with count
if (_comments.length > 0) {
$tabComments.find('span.gra-icon').after(
' <span style="background:#e53935;color:#fff;border-radius:9px;' +
'font-size:10px;padding:0 5px;margin-left:2px;">' + _comments.length + '</span>'
);
// Update toggle badge
var $badge = $( '#gra-toggle-badge' );
if ( $badge.length ) $badge.text( _comments.length ).css('display','flex');
}
});
} );
}() );