MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 518: | Line 518: | ||
+ 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]]) 18:46, 26 April 2026 (UTC)'; | ||
new mw.Api().postWithEditToken({ | new mw.Api().postWithEditToken({ | ||
action:'edit', title:'User_talk:' + ADMIN_USER, section:'new', | action:'edit', title:'User_talk:' + ADMIN_USER, section:'new', | ||
Revision as of 18:46, 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 badgeStyle = 'display:none;position:absolute;top:2px;right:2px;'
+ 'min-width:16px;height:16px;padding:0 4px;'
+ 'background:#e53935;color:#fff;'
+ 'font-size:10px;font-weight:700;line-height:16px;'
+ 'border-radius:8px;text-align:center;'
+ 'pointer-events:none;box-sizing:border-box;';
var $toggle = $( [
'<button id="gra-toggle" title="Comments & Bookmarks" style="position:relative">',
' <span class="gra-icon gra-icon-comment" id="gra-toggle-icon"></span>',
' <span id="gra-toggle-badge" style="' + badgeStyle + '"></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]]) 18:46, 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
// ════════════════════════════════════════════════════════════════════
// ── Badge: keep the toggle-button count bubble in sync ───────────────
// Called from renderCommentCards (which is called by submitComment,
// deleteComment, and switchTab) so the badge is always current.
function updateBadge() {
var $badge = $( '#gra-toggle-badge' );
var $tabBadge = $tabComments.find('.gra-tab-count-badge');
var n = _comments.length;
if ( n > 0 ) {
$badge.text(n).css('display','block');
if ( !$tabBadge.length ) {
$tabComments.find('span.gra-icon').after(
'<span class="gra-tab-count-badge" style="background:#e53935;color:#fff;' +
'border-radius:9px;font-size:10px;padding:0 5px;margin-left:4px;">' + n + '</span>'
);
} else {
$tabBadge.text(n);
}
} else {
$badge.text('').css('display','none');
$tabBadge.remove();
}
}
function renderCommentCards() {
updateBadge();
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();
// Load comments then sync badge/tab count via renderCommentCards → updateBadge
loadComments(function(){ renderCommentCards(); });
} );
}() );