MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 11: | Line 11: | ||
* - Opens an inline composer card anchored to the selection position. | * - Opens an inline composer card anchored to the selection position. | ||
* - On submit: wraps the selected text in a yellow highlight span, | * - On submit: wraps the selected text in a yellow highlight span, | ||
* saves the comment to <PageTitle>/ | * saves the comment to Talk:<PageTitle>/GrComments via MW edit API, | ||
* | * sends email notification to admin, | ||
* and adds a card to the Comments tab of the right panel. | * and adds a card to the Comments tab of the right panel. | ||
* - Each comment card has a delete (×) button (logged-in users only). | |||
* - On delete: removes from wiki page, localStorage anchor, and DOM; | |||
* sends a deletion notification email to admin. | |||
* 4. Clicking Bookmark: | * 4. Clicking Bookmark: | ||
* - Opens a small composer to name the bookmark. | * - Opens a small composer to name the bookmark. | ||
| Line 19: | Line 22: | ||
* saves bookmark in localStorage (per-user, per-page), | * saves bookmark in localStorage (per-user, per-page), | ||
* and adds a card to the Bookmarks tab. | * and adds a card to the Bookmarks tab. | ||
* - Bookmark stores quote + quoteIndex (nth occurrence) so restore | |||
* after page refresh lands on the correct match even when the same | |||
* text appears multiple times on the page (e.g. Sanskrit verses). | |||
* - Clicking a bookmark card scrolls to and flashes that highlight. | * - Clicking a bookmark card scrolls to and flashes that highlight. | ||
* 5. Right panel: | * 5. Right panel: | ||
* - Slides in from the right as an overlay (no layout shift). | * - Slides in from the right as an overlay (no layout shift). | ||
* - Tab 1: Comments (loads from / | * - Tab 1: Comments (loads from Talk:/GrComments wiki page) | ||
* - Tab 2: Bookmarks (loads from localStorage) | * - Tab 2: Bookmarks (loads from localStorage) | ||
* - Panel can be closed via ✕ or clicking the backdrop. | * - Panel can be closed via ✕ or clicking the backdrop. | ||
| Line 31: | Line 37: | ||
* ─────── | * ─────── | ||
* Comments → MediaWiki page: Talk:<PageTitle>/GrComments | * Comments → MediaWiki page: Talk:<PageTitle>/GrComments | ||
* Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}} | * Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}} | ||
* Bookmarks → localStorage ( | * Bookmarks → localStorage Key: grantha_bm_<pageName> | ||
* | * Each entry includes quoteIndex (0-based nth occurrence). | ||
* Highlight anchors → localStorage Key: grantha_cmt_<pageName> | |||
* Stores {id, quote, quoteIndex} for comment highlight restore. | |||
* | * | ||
* ADMIN NOTIFICATION | * ADMIN NOTIFICATION | ||
* ────────────────── | * ────────────────── | ||
* On new comment: sends email via MW EmailUser API | * On new comment AND on comment delete: sends email via MW EmailUser API. | ||
* Fallback: posts to User_talk:ADMIN_USER for Echo notification. | |||
* Fallback: | |||
* Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults). | * Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults). | ||
* | * | ||
| Line 49: | Line 54: | ||
* • mw.Api (MediaWiki core) | * • mw.Api (MediaWiki core) | ||
* • gr_annotations.css (companion stylesheet) | * • gr_annotations.css (companion stylesheet) | ||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
*/ | */ | ||
| Line 69: | Line 67: | ||
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 = 'Talk:' + pageTitle + '/GrComments'; | 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() : '?'; | ||
// Only run on content namespaces | // Only run on content namespaces | ||
if ( window.mw ) { | if ( window.mw ) { | ||
var ns = mw.config.get( 'wgNamespaceNumber' ); | var ns = mw.config.get( 'wgNamespaceNumber' ); | ||
| Line 84: | Line 79: | ||
// ── State ──────────────────────────────────────────────────────────── | // ── State ──────────────────────────────────────────────────────────── | ||
var _selRange = null; | |||
var _selRange = null; | var _selText = ''; | ||
var _selText = ''; | var _selRect = null; | ||
var _selRect = null; | var _comments = []; | ||
var _comments = []; | var _bookmarks = []; | ||
var _bookmarks = []; | |||
var _cmtLoaded = false; | var _cmtLoaded = false; | ||
var _activeTab = 'comments'; | var _activeTab = 'comments'; | ||
// ── Helpers ────────────────────────────────────────────────────────── | // ── Helpers ────────────────────────────────────────────────────────── | ||
| Line 114: | Line 108: | ||
function clamp( val, min, max ) { return Math.max(min, Math.min(max, val)); } | function clamp( val, min, max ) { return Math.max(min, Math.min(max, val)); } | ||
// ── DOM references (populated in | // ── DOM references (populated in buildDom) ─────────────────────────── | ||
var $fab, $panel, $backdrop, $panelBody; | var $fab, $panel, $backdrop, $panelBody; | ||
var $cmpComposer, $cmpInput, $cmpSubmit; | var $cmpComposer, $cmpInput, $cmpSubmit; | ||
| Line 157: | Line 151: | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $cmpComposer ); | $( 'body' ).append( $cmpComposer ); | ||
// ── Bookmark composer card ──────────────────────────────────────── | // ── Bookmark composer card ──────────────────────────────────────── | ||
| Line 199: | Line 193: | ||
$( 'body' ).append( $panel ); | $( 'body' ).append( $panel ); | ||
$backdrop = $('<div id="gra-backdrop"></div>'); | $backdrop = $('<div id="gra-backdrop"></div>'); | ||
$( 'body' ).append( $backdrop ); | $( 'body' ).append( $backdrop ); | ||
var $toggle = $( [ | var $toggle = $( [ | ||
'<button id="gra-toggle" title="Comments & Bookmarks">', | '<button id="gra-toggle" title="Comments & Bookmarks">', | ||
| Line 212: | Line 204: | ||
$( 'body' ).append( $toggle ); | $( 'body' ).append( $toggle ); | ||
$toggle.on( 'click', function() { | $toggle.on( 'click', function() { | ||
if ( $panel.hasClass('gra-panel-open') ) { | if ( $panel.hasClass('gra-panel-open') ) { closePanel(); } | ||
else { openPanel( _activeTab ); } | |||
} ); | } ); | ||
$panelBody = $( '#gra-panel-body' ); | |||
$panelBody | |||
$( '#gra-panel-head div' ).first() | $( '#gra-panel-head div' ).first() | ||
.text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) ) | .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) ) | ||
.css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' }); | .css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' }); | ||
$tabComments | $tabComments = $( '#gra-tab-comments' ); | ||
$tabBookmarks | $tabBookmarks = $( '#gra-tab-bookmarks' ); | ||
$paneComments | $paneComments = $( '#gra-pane-comments' ); | ||
$paneBookmarks = $( '#gra-pane-bookmarks' ); | $paneBookmarks= $( '#gra-pane-bookmarks' ); | ||
$cmpInput | $cmpInput = $( '#gra-cmp-input' ); | ||
$cmpSubmit | $cmpSubmit = $( '#gra-cmp-submit' ); | ||
$bmInput | $bmInput = $( '#gra-bm-input' ); | ||
$bmSubmit | $bmSubmit = $( '#gra-bm-submit' ); | ||
if ( !currentUser ) { | if ( !currentUser ) { | ||
$cmpSubmit.prop( 'disabled', true ); | $cmpSubmit.prop( 'disabled', true ); | ||
| Line 244: | Line 230: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// | // FAB — position & show/hide | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function showFab( rect ) { | function showFab( rect ) { | ||
if ( !rect ) return; | if ( !rect ) return; | ||
var fabW = 46, fabH = 84; | |||
var fabW = 46 | |||
var top = rect.top + ( rect.height / 2 ) - ( fabH / 2 ); | var top = rect.top + ( rect.height / 2 ) - ( fabH / 2 ); | ||
var left = rect.right + 10; | var left = rect.right + 10; | ||
if ( left + fabW > window.innerWidth - 8 ) { left = rect.left - fabW - 10; } | |||
top = clamp( top, 8, window.innerHeight - fabH - 8 ); | |||
if ( left + fabW > window.innerWidth - 8 ) { | left = clamp( left, 8, window.innerWidth - fabW - 8 ); | ||
top = clamp( top, 8, window.innerHeight - fabH - 8 ); | |||
left = clamp( left, 8, window.innerWidth - fabW - 8 ); | |||
$fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible'); | $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible'); | ||
} | } | ||
function hideFab() { | function hideFab() { $fab.removeClass('gra-fab-visible'); } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 278: | Line 253: | ||
var sel = window.getSelection(); | var sel = window.getSelection(); | ||
if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false; | if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false; | ||
var range = sel.getRangeAt(0); | |||
var range | var text = sel.toString().trim(); | ||
var text | |||
if ( !text || text.length < 2 ) return false; | if ( !text || text.length < 2 ) return false; | ||
var contentEl = document.querySelector( CONTENT_SEL ); | var contentEl = document.querySelector( CONTENT_SEL ); | ||
if ( !contentEl ) return false; | if ( !contentEl ) return false; | ||
| Line 289: | Line 261: | ||
if ( startEl.nodeType === 3 ) startEl = startEl.parentNode; | if ( startEl.nodeType === 3 ) startEl = startEl.parentNode; | ||
if ( !contentEl.contains( startEl ) ) return false; | if ( !contentEl.contains( startEl ) ) return false; | ||
_selText = text; | _selText = text; | ||
_selRange = range.cloneRange(); | _selRange = range.cloneRange(); | ||
| Line 302: | Line 273: | ||
function positionComposer( $el ) { | function positionComposer( $el ) { | ||
if ( !_selRect ) return; | if ( !_selRect ) return; | ||
var top = _selRect.bottom + 8; | var top = _selRect.bottom + 8; | ||
var left = _selRect.left; | var left = _selRect.left; | ||
var composerW = 308; | var composerW = 308; | ||
if ( left + composerW > window.innerWidth - 8 ) { | if ( left + composerW > window.innerWidth - 8 ) { left = window.innerWidth - composerW - 8; } | ||
left = Math.max( left, 8 ); | left = Math.max( left, 8 ); | ||
if ( top + 160 > window.innerHeight ) { top = _selRect.top - 170; } | |||
if ( top + 160 > window.innerHeight ) { | |||
top = Math.max( top, 8 ); | top = Math.max( top, 8 ); | ||
$el.css({ top: top + 'px', left: left + 'px' }); | $el.css({ top: top + 'px', left: left + 'px' }); | ||
| Line 325: | Line 289: | ||
function wrapSelection( id, cssClass ) { | function wrapSelection( id, cssClass ) { | ||
if ( !_selRange ) return null; | if ( !_selRange ) return null; | ||
// | // Capture into local var and null out _selRange immediately so | ||
// | // surroundContents() mutation cannot corrupt future selections. | ||
var range = _selRange; | var range = _selRange; | ||
_selRange = null; | _selRange = null; | ||
| Line 337: | Line 300: | ||
return span; | return span; | ||
} catch ( e ) { | } catch ( e ) { | ||
try { | try { | ||
var frag = range.extractContents(); | var frag = range.extractContents(); | ||
var span2 = document.createElement('span'); | var span2 = document.createElement('span'); | ||
span2.className = cssClass; | span2.className = cssClass; | ||
| Line 349: | Line 310: | ||
} catch(e2) { return null; } | } catch(e2) { return null; } | ||
} | } | ||
} | |||
// ════════════════════════════════════════════════════════════════════ | |||
// OCCURRENCE INDEX | |||
// Returns the 0-based count of how many times needle appears in the | |||
// page text *before* the start of savedRange. This tells us which | |||
// occurrence was selected so we can restore the highlight accurately. | |||
// Must be called BEFORE wrapSelection() nulls out _selRange. | |||
// ════════════════════════════════════════════════════════════════════ | |||
function computeQuoteIndex( needle, savedRange ) { | |||
if ( !savedRange || !needle ) return 0; | |||
var contentEl = document.querySelector( CONTENT_SEL ); | |||
if ( !contentEl ) return 0; | |||
try { | |||
var preRange = document.createRange(); | |||
preRange.setStart( contentEl, 0 ); | |||
preRange.setEnd( savedRange.startContainer, savedRange.startOffset ); | |||
var textBefore = preRange.toString(); | |||
var count = 0, idx = 0; | |||
while ( ( idx = textBefore.indexOf( needle, idx ) ) !== -1 ) { | |||
count++; | |||
idx += needle.length; | |||
} | |||
return count; // 0 = first occurrence | |||
} catch(e) { return 0; } | |||
} | } | ||
| Line 357: | Line 344: | ||
function openCommentComposer() { | function openCommentComposer() { | ||
hideFab(); | hideFab(); | ||
positionComposer( $cmpComposer ); | positionComposer( $cmpComposer ); | ||
$cmpComposer.addClass('gra-composer-visible'); | $cmpComposer.addClass('gra-composer-visible'); | ||
| Line 379: | Line 365: | ||
var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' ); | var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' ); | ||
// | // Compute occurrence index BEFORE wrapSelection nulls _selRange | ||
var needle = _selText.replace(/…$/, '').trim().slice(0, 80); | |||
var quoteIndex = computeQuoteIndex( needle, _selRange ); | |||
var span = wrapSelection( id, 'gra-comment-highlight' ); | var span = wrapSelection( id, 'gra-comment-highlight' ); | ||
if ( span ) span.setAttribute('data-gra-quote', quote); | if ( span ) span.setAttribute('data-gra-quote', quote); | ||
var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text }; | var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text }; | ||
_comments.push( entry ); | _comments.push( entry ); | ||
persistCommentHighlight( id, quote, quoteIndex ); | |||
persistCommentHighlight( id, quote ); | |||
saveCommentToWiki( id, quote, text, ts ); | saveCommentToWiki( id, quote, text, ts ); | ||
notifyAdmin( 'add', id, quote, text, ts ); | |||
renderCommentCards(); | renderCommentCards(); | ||
closeCommentComposer(); | closeCommentComposer(); | ||
openPanel('comments'); | openPanel('comments'); | ||
} | |||
// ── Delete comment ─────────────────────────────────────────────────── | |||
function deleteComment( id ) { | |||
var entry = null; | |||
_comments = _comments.filter(function(c){ | |||
if ( c.id === id ) { entry = c; return false; } | |||
return true; | |||
}); | |||
// Remove highlight span from DOM | |||
var span = document.querySelector('[data-gra-id="' + id + '"].gra-comment-highlight'); | |||
if ( span ) { | |||
var parent = span.parentNode; | |||
while ( span.firstChild ) parent.insertBefore( span.firstChild, span ); | |||
parent.removeChild( span ); | |||
} | |||
removeCommentHighlight( id ); | |||
deleteCommentFromWiki( id ); | |||
if ( entry ) { | |||
notifyAdmin( 'delete', id, entry.quote, entry.text, nowIso() ); | |||
} | |||
renderCommentCards(); | |||
} | |||
function deleteCommentFromWiki( id ) { | |||
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['*'] || ''; | |||
} | |||
if ( !existing ) return; | |||
// Remove the {{GrComment…}} block whose id field matches | |||
var re = new RegExp( | |||
'\\{\\{GrComment\\s*\\n(?:[^}]|\\}(?!\\}))*?\\|\\s*id\\s*=\\s*' + | |||
id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + | |||
'[\\s\\S]*?\\}\\}\\n?', 'g' | |||
); | |||
var updated = existing.replace( re, '' ).replace(/\n{3,}/g, '\n\n').trim(); | |||
api.postWithEditToken({ | |||
action:'edit', title:commentsPage, text:updated, | |||
summary:'Deleted comment ' + id + ' by ' + currentUser + ' on [[' + pageTitle + ']]', | |||
bot:0, | |||
}).catch(function(){}); | |||
}).catch(function(){}); | |||
} | } | ||
| Line 411: | Line 448: | ||
var page = (data.query.pages||[])[0]||{}; | var page = (data.query.pages||[])[0]||{}; | ||
var existing = ''; | var existing = ''; | ||
if (page.revisions && page.revisions[0]) { | if ( page.revisions && page.revisions[0] ) { | ||
var rev = page.revisions[0]; | var rev = page.revisions[0]; | ||
existing = rev.slots ? rev.slots.main.content : rev['*'] || ''; | existing = rev.slots ? rev.slots.main.content : rev['*'] || ''; | ||
| Line 431: | Line 468: | ||
} | } | ||
function notifyAdmin( anchorId, quote, commentText, ts ) { | // ── Admin notification (add AND delete) ────────────────────────────── | ||
function notifyAdmin( action, anchorId, quote, commentText, ts ) { | |||
if ( !window.mw || !ADMIN_USER ) return; | if ( !window.mw || !ADMIN_USER ) return; | ||
var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' ).replace( '$1', pageTitle ); | |||
var anchorLink = window.location.origin + articlePath + ( anchorId ? '#' + anchorId : '' ); | |||
var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' ) | var pageDisplay = pageTitle.replace( /_/g, ' ' ); | ||
var isDelete = ( action === 'delete' ); | |||
var anchorLink = window.location.origin + articlePath | |||
var pageDisplay | var subject = isDelete | ||
? '[Grantha] Comment deleted on "' + pageDisplay + '"' | |||
var body | : '[Grantha] New comment on "' + pageDisplay + '"'; | ||
var body = ( isDelete ? 'A comment was DELETED on ' : 'A new comment was posted on ' ) | |||
+ pageDisplay + '.\n\n' | |||
+ ( isDelete ? 'Deleted by : ' : 'Posted by : ' ) | |||
+ ( currentUser || 'Anonymous' ) + '\n' | |||
+ 'Time : ' + ts + '\n' | |||
+ 'Passage : "' + quote + '"\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, | ||
text: | text: body, | ||
token: | token: mw.user.tokens.get( 'csrfToken' ), | ||
}).catch(function(){ | }).catch(function(){ | ||
/* | /* Fallback: post to admin talk page for Echo notification */ | ||
if ( !currentUser ) return; | if ( !currentUser ) return; | ||
var | var verb = isDelete ? 'deleted' : 'posted'; | ||
var wikimsg = '== | var wikimsg = '== Comment ' + verb + ' on [[' + pageDisplay + ']] ==\n' | ||
+ '; By: ' + ( currentUser || 'Anonymous' ) + '\n' | + '; By: ' + ( currentUser || 'Anonymous' ) + '\n' | ||
+ '; Passage: //' + quote + '//\n' | + '; Passage: //' + quote + '//\n' | ||
| Line 480: | Line 512: | ||
+ 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]]) | + '\n\n--[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 11:29, 26 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: ( isDelete ? 'Comment deleted on ' : 'Comment on ' ) + pageDisplay, | ||
text:wikimsg, | text:wikimsg, | ||
summary:'Comment notification from [[' + pageDisplay + ']]', | summary:'Comment notification from [[' + pageDisplay + ']]', | ||
| Line 491: | Line 523: | ||
} | } | ||
// ── Load comments from | // ── Load comments from wiki ─────────────────────────────────────────── | ||
function loadComments( cb ) { | function loadComments( cb ) { | ||
if ( _cmtLoaded ) { if (cb) cb(); return; } | if ( _cmtLoaded ) { if (cb) cb(); return; } | ||
| Line 501: | Line 533: | ||
var page = (data.query.pages||[])[0]||{}; | var page = (data.query.pages||[])[0]||{}; | ||
var wt = ''; | var wt = ''; | ||
if (page.revisions && page.revisions[0]) { | if ( page.revisions && page.revisions[0] ) { | ||
var rev = page.revisions[0]; | var rev = page.revisions[0]; | ||
wt = rev.slots ? rev.slots.main.content : rev['*'] || ''; | wt = rev.slots ? rev.slots.main.content : rev['*'] || ''; | ||
} | } | ||
_comments = parseCommentsWt(wt); | _comments = parseCommentsWt(wt); | ||
_cmtLoaded = true; | _cmtLoaded = true; | ||
if (cb) cb(); | if (cb) cb(); | ||
| Line 513: | Line 545: | ||
function parseCommentsWt( wt ) { | function parseCommentsWt( wt ) { | ||
var out = []; | var out = []; | ||
var re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g; | var re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g; | ||
var m; | var m; | ||
while ((m = re.exec(wt)) !== null) { | while ( (m = re.exec(wt)) !== null ) { | ||
var block = m[1]; | var block = m[1]; | ||
var f = {}; | var f = {}; | ||
var lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g; | var lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g; | ||
var lm; | var lm; | ||
while ((lm = lr.exec(block)) !== null) { | while ( (lm = lr.exec(block)) !== null ) { | ||
f[lm[1].trim()] = lm[2].trim(); | f[lm[1].trim()] = lm[2].trim(); | ||
} | } | ||
| Line 556: | Line 588: | ||
var quote = _selText.slice(0,120) + (_selText.length>120?'…':''); | var quote = _selText.slice(0,120) + (_selText.length>120?'…':''); | ||
// | // Compute occurrence index BEFORE wrapSelection nulls _selRange | ||
var needle = _selText.replace(/…$/, '').trim().slice(0, 80); | |||
var quoteIndex = computeQuoteIndex( needle, _selRange ); | |||
var span = wrapSelection( id, 'gra-bookmark-highlight' ); | var span = wrapSelection( id, 'gra-bookmark-highlight' ); | ||
if ( span ) { | if ( span ) { | ||
span.setAttribute('data-gra-id', id); | span.setAttribute('data-gra-id', id); | ||
span.setAttribute('data-gra-name', name); | span.setAttribute('data-gra-name', name); | ||
} | } | ||
var entry = { id:id, name:name, quote:quote, ts:ts }; | // Store quoteIndex for accurate restore on page reload | ||
var entry = { id:id, name:name, quote:quote, quoteIndex:quoteIndex, ts:ts }; | |||
_bookmarks.push( entry ); | _bookmarks.push( entry ); | ||
persistBookmarks(); | persistBookmarks(); | ||
| Line 573: | Line 609: | ||
function deleteBookmark( id ) { | function deleteBookmark( id ) { | ||
_bookmarks = _bookmarks.filter(function(b){ return b.id !== id; }); | _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; }); | ||
var span = document.querySelector('[data-gra-id="' + id + '"].gra-bookmark-highlight'); | var span = document.querySelector('[data-gra-id="' + id + '"].gra-bookmark-highlight'); | ||
if (span) { | if (span) { | ||
| Line 613: | Line 648: | ||
function switchTab( tab ) { | function switchTab( tab ) { | ||
_activeTab = tab; | _activeTab = tab; | ||
$tabComments.toggleClass('gra-tab-active', tab==='comments'); | $tabComments.toggleClass('gra-tab-active', tab==='comments'); | ||
$tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | ||
$paneComments.toggleClass('gra-pane-active', tab==='comments'); | $paneComments.toggleClass('gra-pane-active', tab==='comments'); | ||
$paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | ||
if (tab==='comments') { | if (tab==='comments') { | ||
| Line 633: | Line 668: | ||
return; | return; | ||
} | } | ||
var canDelete = !!currentUser; | |||
var html = ''; | var html = ''; | ||
_comments.slice().reverse().forEach(function(c){ | _comments.slice().reverse().forEach(function(c){ | ||
| Line 642: | Line 678: | ||
+ (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '') | + (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '') | ||
+ '</div>' | + '</div>' | ||
+ ( canDelete | |||
? '<button class="gra-comment-del" data-del-id="' + esc(c.id) + '" title="Delete comment">×</button>' | |||
: '' ) | |||
+ '</div>' | + '</div>' | ||
+ (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '') | + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '') | ||
| Line 688: | Line 727: | ||
// ── Selection → show FAB ────────────────────────────────────── | // ── Selection → show FAB ────────────────────────────────────── | ||
$( document ).on('mouseup keyup', function( | $( document ).on('mouseup keyup', function(){ | ||
setTimeout(function(){ | setTimeout(function(){ | ||
if ( $cmpComposer.hasClass('gra-composer-visible') ) return; | if ( $cmpComposer.hasClass('gra-composer-visible') ) return; | ||
if ( $bmComposer.hasClass('gra-composer-visible') ) return; | if ( $bmComposer.hasClass('gra-composer-visible') ) return; | ||
if ( captureSelection() ) { | if ( captureSelection() ) { showFab( _selRect ); } | ||
else { hideFab(); } | |||
}, 20); | }, 20); | ||
}); | }); | ||
| Line 713: | Line 748: | ||
// ── FAB: Comment ────────────────────────────────────────────── | // ── FAB: Comment ────────────────────────────────────────────── | ||
$( '#gra-fab-comment' ).on('click', function(e){ | $( '#gra-fab-comment' ).on('click', function(e){ | ||
e.preventDefault(); | e.preventDefault(); e.stopPropagation(); | ||
if ( !captureSelection() ) return; | if ( !captureSelection() ) return; | ||
openCommentComposer(); | openCommentComposer(); | ||
| Line 721: | Line 755: | ||
// ── FAB: Bookmark ───────────────────────────────────────────── | // ── FAB: Bookmark ───────────────────────────────────────────── | ||
$( '#gra-fab-bookmark' ).on('click', function(e){ | $( '#gra-fab-bookmark' ).on('click', function(e){ | ||
e.preventDefault(); | e.preventDefault(); e.stopPropagation(); | ||
if ( !captureSelection() ) return; | if ( !captureSelection() ) return; | ||
openBookmarkComposer(); | openBookmarkComposer(); | ||
| Line 731: | Line 764: | ||
$cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser); | $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser); | ||
}); | }); | ||
$( '#gra-cmp-cancel' ).on('click', function(){ | $( '#gra-cmp-cancel' ).on('click', function(){ closeCommentComposer(); hideFab(); }); | ||
$cmpSubmit.on('click', submitComment); | $cmpSubmit.on('click', submitComment); | ||
$cmpInput.on('keydown', function(e){ | $cmpInput.on('keydown', function(e){ | ||
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); } | if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); } | ||
if (e.key==='Escape') { closeCommentComposer(); hideFab(); } | if (e.key==='Escape') { closeCommentComposer(); hideFab(); } | ||
| Line 743: | Line 772: | ||
// ── Bookmark composer ───────────────────────────────────────── | // ── Bookmark composer ───────────────────────────────────────── | ||
$( '#gra-bm-cancel' ).on('click', function(){ | $( '#gra-bm-cancel' ).on('click', function(){ closeBookmarkComposer(); hideFab(); }); | ||
$bmSubmit.on('click', submitBookmark); | $bmSubmit.on('click', submitBookmark); | ||
$bmInput.on('keydown', function(e){ | $bmInput.on('keydown', function(e){ | ||
if (e.key==='Enter') submitBookmark(); | if (e.key==='Enter') submitBookmark(); | ||
if (e.key==='Escape'){ closeBookmarkComposer(); hideFab(); } | if (e.key==='Escape') { closeBookmarkComposer(); hideFab(); } | ||
}); | }); | ||
| Line 758: | Line 784: | ||
// ── Tab switching ───────────────────────────────────────────── | // ── Tab switching ───────────────────────────────────────────── | ||
$tabComments.on('click', function(){ switchTab('comments'); }); | $tabComments.on('click', function(){ switchTab('comments'); }); | ||
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | ||
// ── Panel: click comment card → scroll to highlight | // ── Panel: click comment card body → scroll to highlight ────── | ||
$paneComments.on('click', '.gra-comment-card', function(){ | $paneComments.on('click', '.gra-comment-card', function(e){ | ||
if ( $(e.target).hasClass('gra-comment-del') ) return; | |||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
if (id) scrollToHighlight(id); | if (id) scrollToHighlight(id); | ||
}); | |||
// ── Panel: delete comment ───────────────────────────────────── | |||
$paneComments.on('click', '.gra-comment-del', function(e){ | |||
e.stopPropagation(); | |||
var id = $(this).attr('data-del-id'); | |||
if ( !id ) return; | |||
if ( !window.confirm('Delete this comment? This cannot be undone.') ) return; | |||
deleteComment(id); | |||
}); | }); | ||
// ── Panel: click bookmark card → scroll to highlight ────────── | // ── Panel: click bookmark card → scroll to highlight ────────── | ||
$paneBookmarks.on('click', '.gra-bookmark-card', function(e){ | $paneBookmarks.on('click', '.gra-bookmark-card', function(e){ | ||
if ($(e.target).hasClass('gra-bookmark-del')) return; | if ( $(e.target).hasClass('gra-bookmark-del') ) return; | ||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
if (id) scrollToHighlight(id); | if (id) scrollToHighlight(id); | ||
| Line 781: | Line 817: | ||
}); | }); | ||
// ── Click | // ── Click highlight in text → open panel ───────────────────── | ||
$( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){ | $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){ | ||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
openPanel('comments'); | openPanel('comments'); | ||
setTimeout(function(){ | setTimeout(function(){ | ||
var $card = $paneComments.find('[data-gra-id="'+id+'"]'); | var $card = $paneComments.find('[data-gra-id="'+id+'"]'); | ||
| Line 805: | Line 840: | ||
}); | }); | ||
// ── Escape key | // ── Escape key ──────────────────────────────────────────────── | ||
$( document ).on('keydown', function(e){ | $( document ).on('keydown', function(e){ | ||
if (e.key==='Escape'){ | if (e.key==='Escape'){ | ||
| Line 816: | Line 851: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// RESTORE | // PERSIST / RESTORE HIGHLIGHT ANCHORS | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function persistCommentHighlight( id, quote, quoteIndex ) { | |||
try { | |||
var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' ); | |||
stored = stored.filter( function(h){ return h.id !== id; } ); | |||
stored.push( { id:id, quote:quote, quoteIndex: quoteIndex || 0 } ); | |||
localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) ); | |||
} catch(e){} | |||
} | |||
function | function removeCommentHighlight( id ) { | ||
try { | try { | ||
var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' ); | var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' ); | ||
stored = stored.filter( function(h){ return h.id !== id; } ); | stored = stored.filter( function(h){ return h.id !== id; } ); | ||
localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) ); | localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) ); | ||
} catch(e){} | } catch(e){} | ||
| Line 845: | Line 879: | ||
var needle = h.quote.replace(/…$/, '').trim().slice(0, 80); | var needle = h.quote.replace(/…$/, '').trim().slice(0, 80); | ||
if ( !needle ) return; | if ( !needle ) return; | ||
var range = | var range = findNthOccurrence( document.querySelector(CONTENT_SEL), needle, h.quoteIndex || 0 ); | ||
if ( range ) { | if ( range ) { | ||
var span = document.createElement('span'); | var span = document.createElement('span'); | ||
| Line 858: | Line 892: | ||
_bookmarks.forEach(function(b){ | _bookmarks.forEach(function(b){ | ||
if ( !b.quote ) return; | if ( !b.quote ) return; | ||
if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return; | if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return; | ||
var contentEl = document.querySelector( CONTENT_SEL ); | var contentEl = document.querySelector( CONTENT_SEL ); | ||
if (!contentEl) return; | if (!contentEl) return; | ||
var needle = b.quote.replace(/…$/,'').trim().slice(0, | var needle = b.quote.replace(/…$/,'').trim().slice(0,80); | ||
if (!needle) return; | if (!needle) return; | ||
// | // Use stored quoteIndex; default to 0 for older entries without it | ||
var found = | var found = findNthOccurrence( contentEl, needle, b.quoteIndex || 0 ); | ||
if (found) { | if (found) { | ||
var span = document.createElement('span'); | var span = document.createElement('span'); | ||
span.className = 'gra-bookmark-highlight'; | span.className = 'gra-bookmark-highlight'; | ||
span.setAttribute('data-gra-id', b.id); | span.setAttribute('data-gra-id', b.id); | ||
span.setAttribute('data-gra-name', b.name); | span.setAttribute('data-gra-name', b.name); | ||
try { | try { found.surroundContents(span); } catch(e){} | ||
} | } | ||
}); | }); | ||
} | } | ||
function | // ════════════════════════════════════════════════════════════════════ | ||
// FIND Nth OCCURRENCE of needle in root's text | |||
// Replaces the old findTextInContent() which always returned the first | |||
// occurrence, causing wrong highlight placement for repeated Sanskrit text. | |||
// ════════════════════════════════════════════════════════════════════ | |||
function findNthOccurrence( root, needle, occurrenceIndex ) { | |||
if ( !root || !needle ) return null; | |||
var text = root.textContent || ''; | var text = root.textContent || ''; | ||
var | |||
if ( | // Find char offset of the Nth occurrence | ||
// Walk | var charIdx = -1; | ||
var count = 0; | |||
var searchFrom = 0; | |||
while ( (charIdx = text.indexOf(needle, searchFrom)) !== -1 ) { | |||
if ( count === occurrenceIndex ) break; | |||
count++; | |||
searchFrom = charIdx + needle.length; | |||
} | |||
if ( charIdx === -1 ) { | |||
// Requested occurrence not found — fall back to first | |||
charIdx = text.indexOf(needle); | |||
if ( charIdx === -1 ) return null; | |||
} | |||
// Walk text nodes to map charIdx → DOM node + offset | |||
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); | var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); | ||
var pos = 0; | var pos = 0; | ||
var node, startNode, startOffset, endNode, endOffset; | var node, startNode, startOffset, endNode, endOffset; | ||
while ((node = iter.nextNode())) { | while ( (node = iter.nextNode()) ) { | ||
var len = node.nodeValue.length; | var len = node.nodeValue.length; | ||
if (!startNode && pos+len > | var endIdx = charIdx + needle.length; | ||
if ( !startNode && pos + len > charIdx ) { | |||
startNode = node; | startNode = node; | ||
startOffset = | startOffset = charIdx - pos; | ||
} | } | ||
if ( startNode && pos + len >= endIdx ) { | |||
if (startNode && pos+len >= endIdx) { | |||
endNode = node; | endNode = node; | ||
endOffset = endIdx - pos; | endOffset = endIdx - pos; | ||
| Line 902: | Line 952: | ||
pos += len; | pos += len; | ||
} | } | ||
if (!startNode || !endNode) return null; | if ( !startNode || !endNode ) return null; | ||
var range = document.createRange(); | var range = document.createRange(); | ||
range.setStart(startNode, startOffset); | range.setStart(startNode, startOffset); | ||
| Line 919: | Line 969: | ||
restoreBookmarkHighlights(); | restoreBookmarkHighlights(); | ||
restoreCommentHighlights(); | restoreCommentHighlights(); | ||
loadComments(function(){ | loadComments(function(){ | ||
if (_comments.length > 0) { | if (_comments.length > 0) { | ||
$tabComments.find('span.gra-icon').after( | $tabComments.find('span.gra-icon').after( | ||
| Line 927: | Line 975: | ||
'font-size:10px;padding:0 5px;margin-left:2px;">' + _comments.length + '</span>' | 'font-size:10px;padding:0 5px;margin-left:2px;">' + _comments.length + '</span>' | ||
); | ); | ||
var $badge = $( '#gra-toggle-badge' ); | var $badge = $( '#gra-toggle-badge' ); | ||
if ( $badge.length ) $badge.text( _comments.length ).css('display','flex'); | if ( $badge.length ) $badge.text( _comments.length ).css('display','flex'); | ||
Revision as of 11:29, 26 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 Talk:<PageTitle>/GrComments via MW edit API,
* sends email notification to admin,
* and adds a card to the Comments tab of the right panel.
* - Each comment card has a delete (×) button (logged-in users only).
* - On delete: removes from wiki page, localStorage anchor, and DOM;
* sends a deletion notification email to admin.
* 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.
* - Bookmark stores quote + quoteIndex (nth occurrence) so restore
* after page refresh lands on the correct match even when the same
* text appears multiple times on the page (e.g. Sanskrit verses).
* - 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 Talk:/GrComments 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
* Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}}
* Bookmarks → localStorage Key: grantha_bm_<pageName>
* Each entry includes quoteIndex (0-based nth occurrence).
* Highlight anchors → localStorage Key: grantha_cmt_<pageName>
* Stores {id, quote, quoteIndex} for comment highlight restore.
*
* ADMIN NOTIFICATION
* ──────────────────
* On new comment AND on comment delete: sends email via MW EmailUser API.
* Fallback: posts to User_talk:ADMIN_USER for Echo notification.
* Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults).
*
* DEPENDENCIES
* ────────────
* • jQuery (loaded by MediaWiki)
* • mw.Api (MediaWiki core)
* • gr_annotations.css (companion stylesheet)
* ══════════════════════════════════════════════════════════════════════
*/
/* 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' ) ) || '';
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
if ( window.mw ) {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns < 0 ) return;
if ( /\/Comments$/.test( pageTitle ) ) return;
}
// ── State ────────────────────────────────────────────────────────────
var _selRange = null;
var _selText = '';
var _selRect = null;
var _comments = [];
var _bookmarks = [];
var _cmtLoaded = false;
var _activeTab = 'comments';
// ── 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 buildDom) ───────────────────────────
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 = $('<div id="gra-backdrop"></div>');
$( 'body' ).append( $backdrop );
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 ); }
} );
$panelBody = $( '#gra-panel-body' );
$( '#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' );
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>' );
}
}
// ════════════════════════════════════════════════════════════════════
// FAB — position & show/hide
// ════════════════════════════════════════════════════════════════════
function showFab( rect ) {
if ( !rect ) return;
var fabW = 46, fabH = 84;
var top = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
var left = rect.right + 10;
if ( left + fabW > window.innerWidth - 8 ) { left = rect.left - fabW - 10; }
top = clamp( top, 8, window.innerHeight - fabH - 8 );
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;
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;
var top = _selRect.bottom + 8;
var left = _selRect.left;
var composerW = 308;
if ( left + composerW > window.innerWidth - 8 ) { left = window.innerWidth - composerW - 8; }
left = Math.max( left, 8 );
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;
// Capture into local var and null out _selRange immediately so
// surroundContents() mutation cannot corrupt future selections.
var range = _selRange;
_selRange = null;
try {
var span = document.createElement('span');
span.className = cssClass;
span.setAttribute('data-gra-id', id);
range.surroundContents( span );
return span;
} catch ( e ) {
try {
var frag = range.extractContents();
var span2 = document.createElement('span');
span2.className = cssClass;
span2.setAttribute('data-gra-id', id);
span2.appendChild( frag );
range.insertNode( span2 );
return span2;
} catch(e2) { return null; }
}
}
// ════════════════════════════════════════════════════════════════════
// OCCURRENCE INDEX
// Returns the 0-based count of how many times needle appears in the
// page text *before* the start of savedRange. This tells us which
// occurrence was selected so we can restore the highlight accurately.
// Must be called BEFORE wrapSelection() nulls out _selRange.
// ════════════════════════════════════════════════════════════════════
function computeQuoteIndex( needle, savedRange ) {
if ( !savedRange || !needle ) return 0;
var contentEl = document.querySelector( CONTENT_SEL );
if ( !contentEl ) return 0;
try {
var preRange = document.createRange();
preRange.setStart( contentEl, 0 );
preRange.setEnd( savedRange.startContainer, savedRange.startOffset );
var textBefore = preRange.toString();
var count = 0, idx = 0;
while ( ( idx = textBefore.indexOf( needle, idx ) ) !== -1 ) {
count++;
idx += needle.length;
}
return count; // 0 = first occurrence
} catch(e) { return 0; }
}
// ════════════════════════════════════════════════════════════════════
// COMMENT FLOW
// ════════════════════════════════════════════════════════════════════
function openCommentComposer() {
hideFab();
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 ? '…' : '' );
// Compute occurrence index BEFORE wrapSelection nulls _selRange
var needle = _selText.replace(/…$/, '').trim().slice(0, 80);
var quoteIndex = computeQuoteIndex( needle, _selRange );
var span = wrapSelection( id, 'gra-comment-highlight' );
if ( span ) span.setAttribute('data-gra-quote', quote);
var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text };
_comments.push( entry );
persistCommentHighlight( id, quote, quoteIndex );
saveCommentToWiki( id, quote, text, ts );
notifyAdmin( 'add', id, quote, text, ts );
renderCommentCards();
closeCommentComposer();
openPanel('comments');
}
// ── Delete comment ───────────────────────────────────────────────────
function deleteComment( id ) {
var entry = null;
_comments = _comments.filter(function(c){
if ( c.id === id ) { entry = c; return false; }
return true;
});
// Remove highlight span from DOM
var span = document.querySelector('[data-gra-id="' + id + '"].gra-comment-highlight');
if ( span ) {
var parent = span.parentNode;
while ( span.firstChild ) parent.insertBefore( span.firstChild, span );
parent.removeChild( span );
}
removeCommentHighlight( id );
deleteCommentFromWiki( id );
if ( entry ) {
notifyAdmin( 'delete', id, entry.quote, entry.text, nowIso() );
}
renderCommentCards();
}
function deleteCommentFromWiki( id ) {
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['*'] || '';
}
if ( !existing ) return;
// Remove the {{GrComment…}} block whose id field matches
var re = new RegExp(
'\\{\\{GrComment\\s*\\n(?:[^}]|\\}(?!\\}))*?\\|\\s*id\\s*=\\s*' +
id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') +
'[\\s\\S]*?\\}\\}\\n?', 'g'
);
var updated = existing.replace( re, '' ).replace(/\n{3,}/g, '\n\n').trim();
api.postWithEditToken({
action:'edit', title:commentsPage, text:updated,
summary:'Deleted comment ' + id + ' by ' + currentUser + ' on [[' + pageTitle + ']]',
bot:0,
}).catch(function(){});
}).catch(function(){});
}
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(){});
}
// ── Admin notification (add AND delete) ──────────────────────────────
function notifyAdmin( action, anchorId, quote, commentText, ts ) {
if ( !window.mw || !ADMIN_USER ) 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 isDelete = ( action === 'delete' );
var subject = isDelete
? '[Grantha] Comment deleted on "' + pageDisplay + '"'
: '[Grantha] New comment on "' + pageDisplay + '"';
var body = ( isDelete ? 'A comment was DELETED on ' : 'A new comment was posted on ' )
+ pageDisplay + '.\n\n'
+ ( isDelete ? 'Deleted by : ' : 'Posted by : ' )
+ ( currentUser || 'Anonymous' ) + '\n'
+ 'Time : ' + ts + '\n'
+ 'Passage : "' + quote + '"\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({
action: 'emailuser',
target: ADMIN_USER,
subject: subject,
text: body,
token: mw.user.tokens.get( 'csrfToken' ),
}).catch(function(){
/* Fallback: post to admin talk page for Echo notification */
if ( !currentUser ) return;
var verb = isDelete ? 'deleted' : 'posted';
var wikimsg = '== Comment ' + verb + ' 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]]) 11:29, 26 April 2026 (UTC)';
new mw.Api().postWithEditToken({
action:'edit', title:'User_talk:' + ADMIN_USER, section:'new',
sectiontitle: ( isDelete ? 'Comment deleted on ' : 'Comment on ' ) + pageDisplay,
text:wikimsg,
summary:'Comment notification from [[' + pageDisplay + ']]',
bot:0,
}).catch(function(){});
});
}
// ── Load comments from wiki ───────────────────────────────────────────
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?'…':'');
// Compute occurrence index BEFORE wrapSelection nulls _selRange
var needle = _selText.replace(/…$/, '').trim().slice(0, 80);
var quoteIndex = computeQuoteIndex( needle, _selRange );
var span = wrapSelection( id, 'gra-bookmark-highlight' );
if ( span ) {
span.setAttribute('data-gra-id', id);
span.setAttribute('data-gra-name', name);
}
// Store quoteIndex for accurate restore on page reload
var entry = { id:id, name:name, quote:quote, quoteIndex:quoteIndex, ts:ts };
_bookmarks.push( entry );
persistBookmarks();
renderBookmarkCards();
closeBookmarkComposer();
openPanel('bookmarks');
}
function deleteBookmark( id ) {
_bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
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 canDelete = !!currentUser;
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>'
+ ( canDelete
? '<button class="gra-comment-del" data-del-id="' + esc(c.id) + '" title="Delete comment">×</button>'
: '' )
+ '</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(){
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){
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 body → scroll to highlight ──────
$paneComments.on('click', '.gra-comment-card', function(e){
if ( $(e.target).hasClass('gra-comment-del') ) return;
var id = $(this).attr('data-gra-id');
if (id) scrollToHighlight(id);
});
// ── Panel: delete comment ─────────────────────────────────────
$paneComments.on('click', '.gra-comment-del', function(e){
e.stopPropagation();
var id = $(this).attr('data-del-id');
if ( !id ) return;
if ( !window.confirm('Delete this comment? This cannot be undone.') ) return;
deleteComment(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 highlight in text → open panel ─────────────────────
$( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
var id = $(this).attr('data-gra-id');
openPanel('comments');
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 ────────────────────────────────────────────────
$( 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();
}
});
}
// ════════════════════════════════════════════════════════════════════
// PERSIST / RESTORE HIGHLIGHT ANCHORS
// ════════════════════════════════════════════════════════════════════
function persistCommentHighlight( id, quote, quoteIndex ) {
try {
var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
stored = stored.filter( function(h){ return h.id !== id; } );
stored.push( { id:id, quote:quote, quoteIndex: quoteIndex || 0 } );
localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
} catch(e){}
}
function removeCommentHighlight( id ) {
try {
var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
stored = stored.filter( function(h){ return h.id !== id; } );
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 = findNthOccurrence( document.querySelector(CONTENT_SEL), needle, h.quoteIndex || 0 );
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;
if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return;
var contentEl = document.querySelector( CONTENT_SEL );
if (!contentEl) return;
var needle = b.quote.replace(/…$/,'').trim().slice(0,80);
if (!needle) return;
// Use stored quoteIndex; default to 0 for older entries without it
var found = findNthOccurrence( contentEl, needle, b.quoteIndex || 0 );
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){}
}
});
}
// ════════════════════════════════════════════════════════════════════
// FIND Nth OCCURRENCE of needle in root's text
// Replaces the old findTextInContent() which always returned the first
// occurrence, causing wrong highlight placement for repeated Sanskrit text.
// ════════════════════════════════════════════════════════════════════
function findNthOccurrence( root, needle, occurrenceIndex ) {
if ( !root || !needle ) return null;
var text = root.textContent || '';
// Find char offset of the Nth occurrence
var charIdx = -1;
var count = 0;
var searchFrom = 0;
while ( (charIdx = text.indexOf(needle, searchFrom)) !== -1 ) {
if ( count === occurrenceIndex ) break;
count++;
searchFrom = charIdx + needle.length;
}
if ( charIdx === -1 ) {
// Requested occurrence not found — fall back to first
charIdx = text.indexOf(needle);
if ( charIdx === -1 ) return null;
}
// Walk text nodes to map charIdx → DOM node + offset
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;
var endIdx = charIdx + needle.length;
if ( !startNode && pos + len > charIdx ) {
startNode = node;
startOffset = charIdx - pos;
}
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();
loadComments(function(){
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>'
);
var $badge = $( '#gra-toggle-badge' );
if ( $badge.length ) $badge.text( _comments.length ).css('display','flex');
}
});
} );
}() );