MediaWiki:Gadget-GrAnnotations.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
* gr_annotations.js — grantha.io inline Comments + Bookmarks (v2)
* ══════════════════════════════════════════════════════════════════════
*
* CHANGES FROM v1
* ────────────────
* • Mobile support: selectionchange + touchend events trigger the FAB
* on iOS/Android. Long-press to select text now shows the comment/
* bookmark strip correctly.
* • Anonymous comments: any visitor (logged-in or not) can comment.
* A name field is shown to anonymous users. Comments are stored on
* Talk:<PageTitle>/GrComments as before and emailed to
* feedback@anandamakaranda.in via mailto: (anon) or MW EmailUser
* (logged-in, falls back to mailto: if email not configured).
* • Email target changed to feedback@anandamakaranda.in.
* • "Add comment" tooltip localised to "टिप्पणी".
* • "Bookmark" tooltip localised to "चिह्नांकन".
*
* BEHAVIOUR
* ──────────
* 1. User selects text anywhere in #mw-content-text (desktop mouse or
* mobile long-press).
* 2. FAB strip (Comment / Bookmark) appears near the selection.
* 3. Clicking Comment:
* – Opens composer. Anonymous users see a "Your name" field.
* – On submit: wraps selection in yellow highlight, saves to wiki,
* emails feedback@anandamakaranda.in, updates panel.
* 4. Clicking Bookmark:
* – Opens name composer. Saves to localStorage, wraps in blue highlight.
* 5. Right panel (slide-in overlay):
* – Tab 1: Comments (loaded from Talk page)
* – Tab 2: Bookmarks (localStorage)
*
* STORAGE
* ───────
* Comments → Talk:<PageTitle>/GrComments ({{GrComment|…}} templates)
* Bookmarks → localStorage key grantha_bm_<pageName>
* Highlight anchors → localStorage key grantha_cmt_<pageName>
*
* NOTIFICATION
* ────────────
* Logged-in : MW EmailUser API → feedback@anandamakaranda.in (admin account)
* Falls back to Talk page notification + mailto: link.
* Anonymous : Opens mailto:feedback@anandamakaranda.in in a new tab.
* The comment is still stored on the wiki Talk page.
* ══════════════════════════════════════════════════════════════════════
*/
/* global mw, $ */
( function () {
'use strict';
// ── Configuration ────────────────────────────────────────────────────
var FEEDBACK_EMAIL = 'feedback@anandamakaranda.in';
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() : '?';
// Run on all content namespaces except the /GrComments page itself
if ( window.mw ) {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns < 0 ) return;
if ( /\/GrComments$/.test( pageTitle ) ) return;
}
// ── State ────────────────────────────────────────────────────────────
var _selRange = null;
var _selText = '';
var _selRect = null;
var _comments = [];
var _bookmarks = [];
var _cmtLoaded = false;
var _activeTab = 'comments';
// Mobile: track whether FAB was dismissed for the current selection
var _selVersion = 0; // incremented on each new selectionchange
var _fabSelVer = -1; // the _selVersion when FAB was last shown
// ── 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( v, lo, hi ) { return Math.max(lo, Math.min(hi, v)); }
function isMobile() { return window.innerWidth < 768 || 'ontouchstart' in window; }
// ── DOM references ───────────────────────────────────────────────────
var $fab, $panel, $backdrop;
var $cmpComposer, $cmpInput, $cmpSubmit, $cmpName;
var $bmComposer, $bmInput, $bmSubmit;
var $tabComments, $tabBookmarks;
var $paneComments, $paneBookmarks;
// ════════════════════════════════════════════════════════════════════
// DOM BUILDER
// ════════════════════════════════════════════════════════════════════
function buildDom() {
// ── FAB strip ────────────────────────────────────────────────────
$fab = $( [
'<div id="gra-fab" role="toolbar" aria-label="टिप्पणी / चिह्नांकन">',
' <button class="gra-fab-btn" id="gra-fab-comment" type="button" aria-label="टिप्पणी जोड़ें">',
' <span class="gra-icon gra-icon-comment" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">टिप्पणी</span>',
' </button>',
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="चिह्नांकन">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">चिह्नांकन</span>',
' </button>',
'</div>',
].join('') );
$( 'body' ).append( $fab );
// ── Comment composer ─────────────────────────────────────────────
// Anonymous users see an extra "Your name" field (hidden for logged-in)
var nameRow = currentUser ? '' : [
' <div class="gra-composer-name-row">',
' <input class="gra-composer-input gra-name-input" id="gra-cmp-name"',
' type="text" placeholder="आपका नाम (ऐच्छिक)" autocomplete="name">',
' </div>',
].join('');
$cmpComposer = $( [
'<div class="gra-composer" id="gra-cmp-composer" role="dialog" aria-label="टिप्पणी जोड़ें">',
' <div class="gra-composer-user">',
' <div class="gra-avatar" id="gra-cmp-avatar">' + esc(currentUser ? userInitial : '?') + '</div>',
' <div class="gra-composer-uname">' + esc(currentUser || 'अतिथि') + '</div>',
' </div>',
nameRow,
' <textarea class="gra-composer-input" id="gra-cmp-input"',
' placeholder="टिप्पणी लिखें…" rows="3"></textarea>',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-cmp-cancel">रद्द</button>',
' <button class="gra-btn-submit" id="gra-cmp-submit" disabled>भेजें</button>',
' </div>',
'</div>',
].join('') );
$( 'body' ).append( $cmpComposer );
// ── Bookmark composer ─────────────────────────────────────────────
$bmComposer = $( [
'<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="चिह्नांकन">',
' <div class="gra-bm-composer-label">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' चिह्नांकन सहेजें',
' </div>',
' <input class="gra-composer-input" id="gra-bm-input"',
' type="text" placeholder="नाम दें…" autocomplete="off">',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-bm-cancel">रद्द</button>',
' <button class="gra-btn-submit" id="gra-bm-submit">सहेजें</button>',
' </div>',
'</div>',
].join('') );
$( 'body' ).append( $bmComposer );
// ── Right panel ───────────────────────────────────────────────────
$panel = $( [
'<div id="gra-panel" role="complementary" aria-label="टिप्पणियाँ">',
' <div id="gra-panel-head">',
' <div id="gra-panel-title"></div>',
' <button id="gra-panel-close" title="बन्द करें">✕</button>',
' </div>',
' <div id="gra-tabs">',
' <button class="gra-tab gra-tab-active" id="gra-tab-comments">',
' <span class="gra-icon gra-icon-comment" aria-hidden="true"></span> टिप्पण्यः',
' </button>',
' <button class="gra-tab" id="gra-tab-bookmarks">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> चिह्नाः',
' </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" aria-hidden="true"></div>' );
$( 'body' ).append( $backdrop );
// Toggle FAB (bottom-right, persistent)
var $toggle = $( [
'<button id="gra-toggle" aria-label="टिप्पणियाँ">',
' <span class="gra-icon gra-icon-comment" id="gra-toggle-icon" aria-hidden="true"></span>',
' <span id="gra-toggle-badge" aria-live="polite"></span>',
'</button>',
].join('') );
$( 'body' ).append( $toggle );
$toggle.on( 'click', function() {
$panel.hasClass('gra-panel-open') ? closePanel() : openPanel( _activeTab );
} );
// Cache
$( '#gra-panel-title' )
.text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) );
$tabComments = $( '#gra-tab-comments' );
$tabBookmarks = $( '#gra-tab-bookmarks' );
$paneComments = $( '#gra-pane-comments' );
$paneBookmarks= $( '#gra-pane-bookmarks' );
$cmpInput = $( '#gra-cmp-input' );
$cmpSubmit = $( '#gra-cmp-submit' );
$cmpName = $( '#gra-cmp-name' ); // may be empty set for logged-in users
$bmInput = $( '#gra-bm-input' );
$bmSubmit = $( '#gra-bm-submit' );
}
// ════════════════════════════════════════════════════════════════════
// SELECTION — desktop + mobile
// ════════════════════════════════════════════════════════════════════
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 start = range.commonAncestorContainer;
if ( start.nodeType === 3 ) start = start.parentNode;
if ( !contentEl.contains( start ) ) return false;
_selText = text;
_selRange = range.cloneRange();
_selRect = range.getBoundingClientRect();
return true;
}
function tryShowFab() {
if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
if ( $bmComposer.hasClass('gra-composer-visible') ) return;
if ( captureSelection() ) {
_fabSelVer = _selVersion;
showFab( _selRect );
} else {
hideFab();
}
}
// ════════════════════════════════════════════════════════════════════
// FAB POSITIONING
// ════════════════════════════════════════════════════════════════════
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'); }
// ════════════════════════════════════════════════════════════════════
// COMPOSER POSITIONING
// ════════════════════════════════════════════════════════════════════
function positionComposer( $el ) {
if ( !_selRect ) return;
var W = 308;
var top = _selRect.bottom + 8;
var left = _selRect.left;
if ( left + W > window.innerWidth - 8 ) left = window.innerWidth - W - 8;
left = Math.max( left, 8 );
if ( top + 200 > window.innerHeight ) top = _selRect.top - 210;
top = Math.max( top, 8 );
// On very small screens center it
if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2;
$el.css({ top: top + 'px', left: left + 'px' });
}
// ════════════════════════════════════════════════════════════════════
// WRAP SELECTION
// ════════════════════════════════════════════════════════════════════
function wrapSelection( id, cssClass ) {
if ( !_selRange ) return null;
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 sp2 = document.createElement('span');
sp2.className = cssClass;
sp2.setAttribute('data-gra-id', id);
sp2.appendChild(frag);
range.insertNode(sp2);
return sp2;
} catch(e2) { return null; }
}
}
// ════════════════════════════════════════════════════════════════════
// COMMENT FLOW
// ════════════════════════════════════════════════════════════════════
function openCommentComposer() {
hideFab();
positionComposer( $cmpComposer );
$cmpComposer.addClass('gra-composer-visible');
// On mobile, delay focus so keyboard doesn't displace the composer
setTimeout( function() { $cmpInput.focus(); }, isMobile() ? 300 : 0 );
}
function closeCommentComposer() {
$cmpComposer.removeClass('gra-composer-visible');
$cmpInput.val('');
if ( $cmpName.length ) $cmpName.val('');
$cmpSubmit.prop('disabled', true);
_selRange = null; _selText = ''; _selRect = null;
}
function submitComment() {
var text = $cmpInput.val().trim();
if ( !text ) return;
// Resolve author name: logged-in user or anonymous name or "अतिथि"
var author = currentUser
|| ( $cmpName.length ? $cmpName.val().trim() : '' )
|| 'अतिथि';
var id = uid();
var ts = nowIso();
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
var span = wrapSelection( id, 'gra-comment-highlight' );
if ( span ) span.setAttribute('data-gra-quote', quote);
var entry = { id:id, author:author, ts:ts, quote:quote, text:text };
_comments.push( entry );
persistCommentHighlight( id, quote );
saveCommentToWiki( id, author, quote, text, ts );
notifyByEmail( id, author, quote, text, ts );
renderCommentCards();
closeCommentComposer();
openPanel('comments');
}
function saveCommentToWiki( id, author, 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 && 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 = ' + author + '\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:'टिप्पणी — ' + author,
bot:0,
}).catch(function(){});
}).catch(function(){});
}
function notifyByEmail( anchorId, author, quote, text, ts ) {
var artPath = (window.mw ? mw.config.get('wgArticlePath') : '/wiki/$1') || '/wiki/$1';
var anchorLink = window.location.origin
+ artPath.replace('$1', pageTitle)
+ (anchorId ? '#' + anchorId : '');
var pageDisplay = pageTitle.replace(/_/g,' ');
var subject = encodeURIComponent('[Grantha] टिप्पणी — "' + pageDisplay + '"');
var body = encodeURIComponent(
'Page : ' + pageDisplay + '\n'
+ 'Author : ' + author + '\n'
+ 'Time : ' + ts + '\n'
+ 'Passage : "' + quote + '"\n\n'
+ 'Comment :\n' + text + '\n\n'
+ '──────────────────────────────────\n'
+ 'Link: ' + anchorLink + '\n'
+ 'Comments page: ' + window.location.origin
+ artPath.replace('$1', 'Talk:' + pageTitle + '/GrComments')
);
// Always notify via mailto to feedback@anandamakaranda.in
var a = document.createElement('a');
a.href = 'mailto:' + FEEDBACK_EMAIL + '?subject=' + subject + '&body=' + body;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ── Load + parse comments ────────────────────────────────────────────
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 && 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 = [], re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g, m;
while ((m = re.exec(wt)) !== null) {
var block = m[1], f = {}, lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g, 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||'अतिथि', ts:f.timestamp||'', quote:f.quote||'', text:f.text||'' });
}
return out;
}
// ════════════════════════════════════════════════════════════════════
// BOOKMARK FLOW
// ════════════════════════════════════════════════════════════════════
function openBookmarkComposer() {
hideFab();
positionComposer( $bmComposer );
$bmComposer.addClass('gra-composer-visible');
setTimeout( function() { $bmInput.focus(); }, isMobile() ? 300 : 0 );
}
function closeBookmarkComposer() {
$bmComposer.removeClass('gra-composer-visible');
$bmInput.val('');
_selRange = null; _selText = ''; _selRect = null;
}
function submitBookmark() {
var name = $bmInput.val().trim() || ('चिह्न ' + (_bookmarks.length + 1));
var id = uid();
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
var span = wrapSelection(id, 'gra-bookmark-highlight');
if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
_bookmarks.push({ id:id, name:name, quote:quote, ts:nowIso() });
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 r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
}
// ════════════════════════════════════════════════════════════════════
// PANEL
// ════════════════════════════════════════════════════════════════════
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) {
$paneComments.html('<div class="gra-empty-state">अभी कोई टिप्पणी नहीं।<br>पाठ चुनें और 💬 पर क्लिक करें।</div>');
return;
}
var html = '';
_comments.slice().reverse().forEach(function(c){
html += '<div class="gra-comment-card" data-gra-id="' + esc(c.id) + '">'
+ '<div class="gra-card-header">'
+ '<div class="gra-avatar">' + esc((c.author||'?').charAt(0).toUpperCase()) + '</div>'
+ '<div class="gra-card-meta">'
+ '<div class="gra-card-author">' + esc(c.author) + '</div>'
+ (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
+ '</div></div>'
+ (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
+ '<div class="gra-card-text">' + esc(c.text) + '</div>'
+ '</div>';
});
$paneComments.html(html);
}
function renderBookmarkCards() {
if (!_bookmarks.length) {
$paneBookmarks.html('<div class="gra-empty-state">अभी कोई चिह्न नहीं।<br>पाठ चुनें और 🔖 पर क्लिक करें।</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" aria-hidden="true"></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="हटाएं">×</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 (desktop + mobile)
// ════════════════════════════════════════════════════════════════════
function wireEvents() {
// ── Desktop: mouseup / keyup ──────────────────────────────────
$( document ).on('mouseup keyup', function(e){
if ( e.type === 'mouseup' && e.button !== 0 ) return; // left-click only
setTimeout( tryShowFab, 20 );
});
// ── Mobile: selectionchange (iOS + Android) ───────────────────
// 'selectionchange' fires continuously while user drags selection handles.
// We debounce it and only act after the user has stopped for 400ms.
var _selChangeTimer = null;
document.addEventListener('selectionchange', function() {
_selVersion++;
clearTimeout( _selChangeTimer );
var v = _selVersion;
_selChangeTimer = setTimeout(function(){
// Only trigger if this is still the latest selectionchange
if ( v !== _selVersion ) return;
if ( _fabSelVer === v ) return; // already showed FAB for this selection
tryShowFab();
}, 400);
});
// ── Mobile: touchend fallback (catch tap-to-end-selection) ────
document.addEventListener('touchend', function(e){
// Don't fire if tapping the FAB itself
if ( $fab[0] && $fab[0].contains(e.target) ) return;
if ( $cmpComposer[0] && $cmpComposer[0].contains(e.target) ) return;
if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return;
// Small delay — browser settles selection after touchend
setTimeout( tryShowFab, 80 );
}, { passive: true });
// ── Click outside → hide FAB ──────────────────────────────────
$( document ).on('mousedown touchstart', function(e){
var t = e.target;
if ( $fab[0] && $fab[0].contains(t) ) return;
if ( $cmpComposer[0] && $cmpComposer[0].contains(t) ) return;
if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return;
hideFab();
});
// ── FAB buttons ───────────────────────────────────────────────
$( '#gra-fab-comment' ).on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
// Re-capture in case selection was cleared by the tap
if ( !_selRange && !captureSelection() ) return;
openCommentComposer();
});
$( '#gra-fab-bookmark' ).on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
if ( !_selRange && !captureSelection() ) return;
openBookmarkComposer();
});
// ── Comment composer ──────────────────────────────────────────
$cmpInput.on('input', function(){
$cmpSubmit.prop('disabled', !$( this ).val().trim());
});
$( '#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 ─────────────────────────────────────────────────────
$( '#gra-panel-close' ).on('click', closePanel);
$backdrop.on('click touchstart', closePanel);
$tabComments.on('click', function(){ switchTab('comments'); });
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
// Click comment card → scroll to highlight
$paneComments.on('click', '.gra-comment-card', function(){
var id = $( this ).attr('data-gra-id');
if (id) { closePanel(); scrollToHighlight(id); }
});
// Click bookmark card → scroll
$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) { closePanel(); scrollToHighlight(id); }
});
// Delete bookmark
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){
e.stopPropagation();
var id = $( this ).attr('data-del-id');
if (id) deleteBookmark(id);
});
// 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
$( document ).on('keydown', function(e){
if (e.key !== 'Escape') return;
if ($cmpComposer.hasClass('gra-composer-visible')) { closeCommentComposer(); hideFab(); }
else if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); }
else closePanel();
});
}
// ════════════════════════════════════════════════════════════════════
// RESTORE HIGHLIGHTS on page reload
// ════════════════════════════════════════════════════════════════════
function persistCommentHighlight(id, quote) {
try {
var s = JSON.parse(localStorage.getItem(CMT_LS_KEY)||'[]');
s = s.filter(function(h){ return h.id !== id; });
s.push({id:id, quote:quote});
localStorage.setItem(CMT_LS_KEY, JSON.stringify(s));
} catch(e){}
}
function restoreCommentHighlights() {
var s = [];
try { s = JSON.parse(localStorage.getItem(CMT_LS_KEY)||'[]'); } catch(e){}
s.forEach(function(h){
if (!h.quote || !h.id) return;
if (document.querySelector('[data-gra-id="'+h.id+'"].gra-comment-highlight')) return;
var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
if (!needle) return;
var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
if (range) {
var sp = document.createElement('span');
sp.className = 'gra-comment-highlight';
sp.setAttribute('data-gra-id', h.id);
try { range.surroundContents(sp); } 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 needle = b.quote.replace(/…$/,'').trim().slice(0,60);
if (!needle) return;
var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
if (found) {
var sp = document.createElement('span');
sp.className = 'gra-bookmark-highlight';
sp.setAttribute('data-gra-id', b.id);
sp.setAttribute('data-gra-name', b.name);
try { found.surroundContents(sp); } catch(e){}
}
});
}
function findTextInContent(root, needle) {
if (!root) return null;
var text = root.textContent || '';
var idx = text.indexOf(needle);
if (idx < 0) return null;
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
var pos = 0, node, startNode, startOffset, endNode, endOffset;
while ((node = iter.nextNode())) {
var len = node.nodeValue.length;
if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; }
var endIdx = idx + needle.length;
if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; }
pos += len;
}
if (!startNode || !endNode) return null;
var r = document.createRange();
r.setStart(startNode, startOffset);
r.setEnd(endNode, endOffset);
return r;
}
// ════════════════════════════════════════════════════════════════════
// BOOT
// ════════════════════════════════════════════════════════════════════
$( function () {
buildDom();
wireEvents();
loadBookmarks();
restoreBookmarkHighlights();
restoreCommentHighlights();
loadComments(function(){
if (_comments.length > 0) {
$tabComments.find('.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');
}
});
});
}() );