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'; // MW username for talk-page fallback | ||
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 148: | Line 149: | ||
' <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=" | ' placeholder="Describe the issue…" rows="3"', | ||
' 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 157: | Line 164: | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
// ── Bookmark composer card ──────────────────────────────────────── | // ── Bookmark composer card ──────────────────────────────────────── | ||
| Line 360: | Line 367: | ||
function closeCommentComposer() { | function closeCommentComposer() { | ||
$cmpComposer.removeClass('gra-composer-visible'); | $cmpComposer.removeClass('gra-composer-visible'); | ||
$cmpInput.val(''); | $( '#gra-quick-chips .gra-chip' ).removeClass('gra-chip-active'); | ||
$cmpInput.hide().val(''); | |||
$cmpSubmit.prop('disabled', true); | $cmpSubmit.prop('disabled', true); | ||
_selRange = null; | _selRange = null; | ||
| Line 424: | Line 432: | ||
function notifyAdmin( anchorId, quote, commentText, ts ) { | function notifyAdmin( anchorId, quote, commentText, ts ) { | ||
/* Send | /* Send notification email directly using MW's EmailUser API, targeting | ||
* | * ADMIN_EMAIL via the ADMIN_USER account — no server-side SMTP config needed. | ||
* ADMIN_EMAIL is set at the top of this file; change it to any address. */ | |||
if ( !window.mw ) return; | |||
* | |||
if ( !window.mw | |||
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 = 'Page : ' + pageDisplay + '\n' | ||
+ ' | + 'By : ' + ( currentUser || 'Anonymous' ) + '\n' | ||
+ 'Time | + 'Time : ' + ts + '\n' | ||
+ 'Passage | + 'Passage : "' + quote + '"\n\n' | ||
+ 'Comment | + 'Comment :\n' + commentText + '\n\n' | ||
+ ' | + 'Link : ' + anchorLink + '\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: | action: 'emailuser', | ||
target: | target: ADMIN_USER, | ||
subject: | subject: subject, | ||
text: | text: body, | ||
token: | token: mw.user.tokens.get( 'csrfToken' ), | ||
}).catch(function(){ | }).catch(function(){ | ||
/* | /* Fallback: post a section to admin talk page for Echo notification */ | ||
if ( !currentUser ) return; | if ( !currentUser ) return; | ||
var wikimsg = '== New comment on [[' + pageDisplay + ']] ==\n' | var wikimsg = '== New comment on [[' + pageDisplay + ']] ==\n' | ||
+ '; By: ' + ( currentUser || 'Anonymous' ) + '\n' | + '; By: ' + ( currentUser || 'Anonymous' ) + '\n' | ||
| Line 471: | Line 467: | ||
+ '; 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]]) 18:12, 25 April 2026 (UTC)'; | ||
new mw.Api().postWithEditToken({ | new mw.Api().postWithEditToken({ | ||
action:'edit', title: | action:'edit', title:'User_talk:' + ADMIN_USER, section:'new', | ||
sectiontitle:'Comment on ' + pageDisplay, | sectiontitle:'Comment on ' + pageDisplay, | ||
text:wikimsg, | text:wikimsg, | ||
| Line 720: | Line 716: | ||
// ── 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); | ||
Revision as of 18:12, 25 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'; // MW username for talk-page fallback
var ADMIN_EMAIL = 'admin@grantha.io'; // ← set your email here directly
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>',
' <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>',
' <textarea class="gra-composer-input" id="gra-cmp-input"',
' placeholder="Describe the issue…" rows="3"',
' style="display:none"></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('') );
// ── 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');
$( '#gra-quick-chips .gra-chip' ).removeClass('gra-chip-active');
$cmpInput.hide().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 notification email directly using MW's EmailUser API, targeting
* ADMIN_EMAIL via the ADMIN_USER account — no server-side SMTP config needed.
* ADMIN_EMAIL is set at the top of this file; change it to any address. */
if ( !window.mw ) return;
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 = 'Page : ' + pageDisplay + '\n'
+ 'By : ' + ( currentUser || 'Anonymous' ) + '\n'
+ 'Time : ' + ts + '\n'
+ 'Passage : "' + quote + '"\n\n'
+ 'Comment :\n' + commentText + '\n\n'
+ 'Link : ' + anchorLink + '\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({
action: 'emailuser',
target: ADMIN_USER,
subject: subject,
text: body,
token: mw.user.tokens.get( 'csrfToken' ),
}).catch(function(){
/* Fallback: post a section to admin talk page for Echo notification */
if ( !currentUser ) return;
var wikimsg = '== New comment on [[' + pageDisplay + ']] ==\n'
+ '; By: ' + ( currentUser || 'Anonymous' ) + '\n'
+ '; Passage: //' + quote + '//\n'
+ '; Link: ' + anchorLink + '\n\n'
+ commentText.slice(0,500)
+ ( commentText.length > 500 ? '\n...' : '' )
+ '\n\n[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 18:12, 25 April 2026 (UTC)';
new mw.Api().postWithEditToken({
action:'edit', title:'User_talk:' + ADMIN_USER, 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 ──────────────────────────────────────────
/* 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(){
$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');
}
});
} );
}() );