MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
Created page with "/** * gr_annotations.js — grantha.io inline Comments + Bookmarks * ══════════════════════════════════════════════════════════════════════ * * BEHAVIOUR (mirrors Google Docs) * ──────────────────────────────── * 1. User selects text anywhere in mw-content-text...." |
No edit summary |
||
| (11 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline Comments + Bookmarks | * gr_annotations.js — grantha.io inline Comments + Bookmarks (v2) | ||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
* | * | ||
* | * CHANGES FROM v1 | ||
* | * ──────────────── | ||
* 1. User selects text anywhere in mw-content-text. | * • Mobile support: selectionchange + touchend events trigger the FAB | ||
* 2. | * 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: | * 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: | * 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) | |||
* 5. Right panel | |||
* | |||
* | |||
* | * | ||
* STORAGE | * STORAGE | ||
* ─────── | * ─────── | ||
* Comments → | * Comments → Talk:<PageTitle>/GrComments ({{GrComment|…}} templates) | ||
* Bookmarks → localStorage key grantha_bm_<pageName> | |||
* Highlight anchors → localStorage key grantha_cmt_<pageName> | |||
* Bookmarks → localStorage key | |||
* | |||
* | * | ||
* | * 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. | ||
* | |||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
*/ | */ | ||
| Line 61: | Line 52: | ||
// ── Configuration ──────────────────────────────────────────────────── | // ── Configuration ──────────────────────────────────────────────────── | ||
var | var FEEDBACK_EMAIL = 'feedback@anandamakaranda.in'; | ||
var CONTENT_SEL = '#mw-content-text'; | var CONTENT_SEL = '#mw-content-text'; | ||
var BM_LS_KEY = ' | var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
var pageTitle | var CMT_LS_KEY = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
var commentsPage = pageTitle + '/ | var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | ||
var currentUser | var commentsPage = 'Talk:' + pageTitle + '/GrComments'; | ||
var userInitial | 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 ) { | if ( window.mw ) { | ||
var ns = mw.config.get( 'wgNamespaceNumber' ); | var ns = mw.config.get( 'wgNamespaceNumber' ); | ||
if ( ns < 0 ) return; | if ( ns < 0 ) return; | ||
if ( /\/ | if ( /\/GrComments$/.test( pageTitle ) ) return; | ||
} | } | ||
// ── 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'; | ||
// 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 ────────────────────────────────────────────────────────── | // ── Helpers ────────────────────────────────────────────────────────── | ||
| Line 105: | Line 100: | ||
} catch(e){ return ts; } | } catch(e){ return ts; } | ||
} | } | ||
function clamp( | function clamp( v, lo, hi ) { return Math.max(lo, Math.min(hi, v)); } | ||
function isMobile() { return window.innerWidth < 768 || 'ontouchstart' in window; } | |||
// ── DOM references | // ── DOM references ─────────────────────────────────────────────────── | ||
var $fab, $panel, $backdrop | var $fab, $panel, $backdrop; | ||
var $cmpComposer, $cmpInput, $cmpSubmit; | var $cmpComposer, $cmpInput, $cmpSubmit, $cmpName; | ||
var $bmComposer, $bmInput, $bmSubmit; | var $bmComposer, $bmInput, $bmSubmit; | ||
var $tabComments, $tabBookmarks; | var $tabComments, $tabBookmarks; | ||
| Line 120: | Line 116: | ||
function buildDom() { | function buildDom() { | ||
// ── | // ── FAB strip ──────────────────────────────────────────────────── | ||
$fab = $( [ | $fab = $( [ | ||
'<div id="gra-fab">', | '<div id="gra-fab" role="toolbar" aria-label="Comment / Bookmark">', | ||
' <button class="gra-fab-btn" id="gra-fab-comment" type="button">', | ' <button class="gra-fab-btn" id="gra-fab-comment" type="button" aria-label="Add comment">', | ||
' <span class="gra-icon gra-icon-comment"></span>', | ' <span class="gra-icon gra-icon-comment" aria-hidden="true"></span>', | ||
' <span class="gra-fab-tooltip"> | ' <span class="gra-fab-tooltip">Comment</span>', | ||
' </button>', | ' </button>', | ||
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button">', | ' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">', | ||
' <span class="gra-icon gra-icon-bookmark"></span>', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', | ||
' <span class="gra-fab-tooltip">Bookmark</span>', | ' <span class="gra-fab-tooltip">Bookmark</span>', | ||
' </button>', | ' </button>', | ||
| Line 135: | Line 131: | ||
$( 'body' ).append( $fab ); | $( 'body' ).append( $fab ); | ||
// ── Comment composer | // ── 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="Your name (optional)" autocomplete="name">', | |||
' </div>', | |||
].join(''); | |||
$cmpComposer = $( [ | $cmpComposer = $( [ | ||
'<div class="gra-composer" id="gra-cmp-composer">', | '<div class="gra-composer" id="gra-cmp-composer" role="dialog" aria-label="Add comment">', | ||
' <div class="gra-composer-user">', | ' <div class="gra-composer-user">', | ||
' <div class="gra-avatar" id="gra-cmp-avatar">' + esc(userInitial) + '</div>', | ' <div class="gra-avatar" id="gra-cmp-avatar">' + esc(currentUser ? userInitial : '?') + '</div>', | ||
' <div class="gra-composer- | ' <div class="gra-composer-uname">' + esc(currentUser || 'Guest') + '</div>', | ||
' </div>', | ' </div>', | ||
nameRow, | |||
' <textarea class="gra-composer-input" id="gra-cmp-input"', | ' <textarea class="gra-composer-input" id="gra-cmp-input"', | ||
' placeholder=" | ' placeholder="Write a comment…" rows="3"></textarea>', | ||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>', | ' <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>', | ||
| Line 152: | Line 157: | ||
$( 'body' ).append( $cmpComposer ); | $( 'body' ).append( $cmpComposer ); | ||
// ── Bookmark composer | // ── Bookmark composer ───────────────────────────────────────────── | ||
$bmComposer = $( [ | $bmComposer = $( [ | ||
'<div class="gra-bm-composer" id="gra-bm-composer">', | '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">', | ||
' <div class="gra-bm-composer-label">', | ' <div class="gra-bm-composer-label">', | ||
' <span class="gra-icon gra-icon-bookmark"></span>', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', | ||
' Save bookmark', | ' Save bookmark', | ||
' </div>', | ' </div>', | ||
' <input class="gra-composer-input" id="gra-bm-input"', | ' <input class="gra-composer-input" id="gra-bm-input"', | ||
' type="text" placeholder=" | ' type="text" placeholder="Name this bookmark…" autocomplete="off">', | ||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>', | ' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>', | ||
| Line 171: | Line 176: | ||
// ── Right panel ─────────────────────────────────────────────────── | // ── Right panel ─────────────────────────────────────────────────── | ||
$panel = $( [ | $panel = $( [ | ||
'<div id="gra-panel">', | '<div id="gra-panel" role="complementary" aria-label="Comments">', | ||
' <div id="gra-panel-head">', | ' <div id="gra-panel-head">', | ||
' <div></div>', | ' <div id="gra-panel-title"></div>', | ||
' <button id="gra-panel-close" title="Close | ' <button id="gra-panel-close" title="Close">✕</button>', | ||
' </div>', | ' </div>', | ||
' <div id="gra-tabs">', | ' <div id="gra-tabs">', | ||
' <button class="gra-tab gra-tab-active" id="gra-tab-comments">', | ' <button class="gra-tab gra-tab-active" id="gra-tab-comments">', | ||
' <span class="gra-icon gra-icon-comment"></span> Comments', | ' <span class="gra-icon gra-icon-comment" aria-hidden="true"></span> Comments', | ||
' </button>', | ' </button>', | ||
' <button class="gra-tab" id="gra-tab-bookmarks">', | ' <button class="gra-tab" id="gra-tab-bookmarks">', | ||
' <span class="gra-icon gra-icon-bookmark"></span> Bookmarks', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', | ||
' </button>', | ' </button>', | ||
' </div>', | ' </div>', | ||
| Line 192: | Line 197: | ||
$( 'body' ).append( $panel ); | $( 'body' ).append( $panel ); | ||
$backdrop = $( '<div id="gra-backdrop" aria-hidden="true"></div>' ); | |||
$backdrop = $('<div id="gra-backdrop"></div>'); | |||
$( 'body' ).append( $backdrop ); | $( 'body' ).append( $backdrop ); | ||
// | // Toggle FAB (bottom-right, persistent) | ||
var $toggle = $( [ | |||
$ | '<button id="gra-toggle" aria-label="Comments">', | ||
' <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 | // SELECTION — desktop + mobile | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 246: | Line 233: | ||
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; | ||
var | var start = range.commonAncestorContainer; | ||
if ( | if ( start.nodeType === 3 ) start = start.parentNode; | ||
if ( !contentEl.contains( | if ( !contentEl.contains( start ) ) return false; | ||
_selText = text; | _selText = text; | ||
| Line 263: | Line 248: | ||
return true; | 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'); } | |||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 270: | Line 283: | ||
function positionComposer( $el ) { | function positionComposer( $el ) { | ||
if ( !_selRect ) return; | if ( !_selRect ) return; | ||
var | var W = 308; | ||
var | var top = _selRect.bottom + 8; | ||
var | 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' }); | $el.css({ top: top + 'px', left: left + 'px' }); | ||
} | } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// WRAP SELECTION | // WRAP SELECTION | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function wrapSelection( id, cssClass ) { | function wrapSelection( id, cssClass ) { | ||
if ( !_selRange ) return null; | if ( !_selRange ) return null; | ||
var range = _selRange; | |||
_selRange = null; | |||
try { | try { | ||
var span = document.createElement('span'); | var span = document.createElement('span'); | ||
span.className = cssClass; | span.className = cssClass; | ||
span.setAttribute('data-gra-id', id); | span.setAttribute('data-gra-id', id); | ||
range.surroundContents( span ); | |||
return span; | return span; | ||
} catch ( e ) { | } catch (e) { | ||
try { | try { | ||
var frag = | var frag = range.extractContents(); | ||
var | var sp2 = document.createElement('span'); | ||
sp2.className = cssClass; | |||
sp2.setAttribute('data-gra-id', id); | |||
sp2.appendChild(frag); | |||
range.insertNode(sp2); | |||
return | return sp2; | ||
} catch(e2) { return null; } | } catch(e2) { return null; } | ||
} | } | ||
| Line 312: | Line 328: | ||
function openCommentComposer() { | function openCommentComposer() { | ||
hideFab(); | hideFab(); | ||
positionComposer( $cmpComposer ); | positionComposer( $cmpComposer ); | ||
$cmpComposer.addClass('gra-composer-visible'); | $cmpComposer.addClass('gra-composer-visible'); | ||
$cmpInput. | // On mobile, delay focus so keyboard doesn't displace the composer | ||
setTimeout( function() { $cmpInput.focus(); }, isMobile() ? 300 : 0 ); | |||
} | } | ||
| Line 321: | Line 337: | ||
$cmpComposer.removeClass('gra-composer-visible'); | $cmpComposer.removeClass('gra-composer-visible'); | ||
$cmpInput.val(''); | $cmpInput.val(''); | ||
if ( $cmpName.length ) $cmpName.val(''); | |||
$cmpSubmit.prop('disabled', true); | $cmpSubmit.prop('disabled', true); | ||
_selRange = null; | _selRange = null; _selText = ''; _selRect = null; | ||
} | } | ||
function submitComment() { | function submitComment() { | ||
var text | var text = $cmpInput.val().trim(); | ||
if ( !text || | if ( !text ) return; | ||
// Resolve author name: logged-in user or anonymous name or "अतिथि" | |||
var author = currentUser | |||
|| ( $cmpName.length ? $cmpName.val().trim() : '' ) | |||
|| 'Guest'; | |||
var id = uid(); | var id = uid(); | ||
var ts = nowIso(); | var ts = nowIso(); | ||
var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' ); | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | ||
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:author, ts:ts, quote:quote, text:text }; | |||
var entry = { id:id, author: | |||
_comments.push( entry ); | _comments.push( entry ); | ||
persistCommentHighlight( id, quote ); | |||
saveCommentToWiki( id, author, quote, text, ts, id ); | |||
notifyByEmail( id, author, quote, text, ts ); | |||
renderCommentCards(); | renderCommentCards(); | ||
closeCommentComposer(); | closeCommentComposer(); | ||
| Line 352: | Line 369: | ||
} | } | ||
function saveCommentToWiki( id, quote, text, ts ) { | function saveCommentToWiki( id, author, quote, text, ts, anchorId ) { | ||
if ( !window.mw ) return; | if ( !window.mw ) return; | ||
var api = new mw.Api(); | var api = new mw.Api(); | ||
| Line 359: | Line 376: | ||
rvprop:'content', rvslots:'main', formatversion:2, | rvprop:'content', rvslots:'main', formatversion:2, | ||
}).then(function(data){ | }).then(function(data){ | ||
var page = (data.query.pages||[])[0]||{}; | var page = ((data.query && data.query.pages)||[])[0]||{}; | ||
var existing = ''; | var existing = ''; | ||
if (page.revisions && page.revisions[0]) { | if (page.revisions && page.revisions[0]) { | ||
| Line 366: | Line 383: | ||
} | } | ||
var entry = '{{GrComment\n' | var entry = '{{GrComment\n' | ||
+ '| id = ' + id | + '| id = ' + id + '\n' | ||
+ '| author = ' + | + '| author = ' + author + '\n' | ||
+ '| timestamp = ' + ts | + '| timestamp = ' + ts + '\n' | ||
+ '| quote = ' + quote.replace(/\n/g,' ') + '\n' | + '| quote = ' + quote.replace(/\n/g,' ') + '\n' | ||
+ '| text = ' + text.replace(/\n/g,' ') + '\n' | + '| text = ' + text.replace(/\n/g,' ') + '\n' | ||
| Line 375: | Line 392: | ||
api.postWithEditToken({ | api.postWithEditToken({ | ||
action:'edit', title:commentsPage, text:updated, | action:'edit', title:commentsPage, text:updated, | ||
summary:' | summary:'Comment — ' + author, | ||
bot:0, | bot:0, | ||
}).then(function(){ | |||
// Silently notify admin by appending a compact notice to their Talk page. | |||
// MediaWiki Echo picks this up and sends an email to whoever watches that page. | |||
// This is completely invisible to the commenter — no browser hijack. | |||
var adminTalk = 'User_talk:Chandrashekars'; | |||
var artPath = (mw.config.get('wgArticlePath')||'/wiki/$1'); | |||
var link = window.location.origin | |||
+ artPath.replace('$1', pageTitle) | |||
+ (anchorId ? '#' + anchorId : ''); | |||
var notice = '\n<!-- grantha-comment-notify -->\n' | |||
+ '; [[' + pageTitle.replace(/_/g,' ') + ']] — ' + author + '\n' | |||
+ ': ' + quote.slice(0,80) + '\n' | |||
+ ': ' + link + '\n'; | |||
new mw.Api().postWithEditToken({ | |||
action:'edit', title:adminTalk, section:'new', | |||
sectiontitle:'New comment on ' + pageTitle.replace(/_/g,' '), | |||
text:notice, | |||
summary:'Comment notification', | |||
bot:1, /* bot=1 suppresses Echo "someone edited your talk page" popup | |||
but still triggers email watchlist notifications */ | |||
}).catch(function(){}); /* silent — never block the comment flow */ | |||
}).catch(function(){}); | }).catch(function(){}); | ||
}).catch(function(){}); | }).catch(function(){}); | ||
} | } | ||
function | function notifyByEmail( anchorId, author, quote, text, ts ) { | ||
/* Notification is handled server-side by MediaWiki's Echo extension | |||
* when saveCommentToWiki() edits the Talk page — no client-side | |||
* mailto needed. A mailto would abruptly open the user's mail app | |||
* mid-session which breaks the commenting flow entirely. */ | |||
} | } | ||
// ── Load comments | // ── Load + parse comments ──────────────────────────────────────────── | ||
function loadComments( cb ) { | function loadComments( cb ) { | ||
if ( _cmtLoaded ) { if (cb) cb(); return; } | if ( _cmtLoaded ) { if (cb) cb(); return; } | ||
if ( !window.mw ) { _cmtLoaded=true; if(cb)cb(); return; } | if ( !window.mw ) { _cmtLoaded = true; if (cb) cb(); return; } | ||
new mw.Api().get({ | new mw.Api().get({ | ||
action:'query', prop:'revisions', titles:commentsPage, | action:'query', prop:'revisions', titles:commentsPage, | ||
rvprop:'content', rvslots:'main', formatversion:2, | rvprop:'content', rvslots:'main', formatversion:2, | ||
}).then(function(data){ | }).then(function(data){ | ||
var page = (data.query.pages||[])[0]||{}; | var page = ((data.query && data.query.pages)||[])[0]||{}; | ||
var wt = ''; | var wt = ''; | ||
if (page.revisions && page.revisions[0]) { | if (page.revisions && page.revisions[0]) { | ||
| Line 417: | Line 443: | ||
_cmtLoaded = true; | _cmtLoaded = true; | ||
if (cb) cb(); | if (cb) cb(); | ||
}).catch(function(){ _cmtLoaded=true; if(cb)cb(); }); | }).catch(function(){ _cmtLoaded = true; if (cb) cb(); }); | ||
} | } | ||
function parseCommentsWt( wt ) { | function parseCommentsWt( wt ) { | ||
var out = [] | var out = [], re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g, m; | ||
while ((m = re.exec(wt)) !== null) { | while ((m = re.exec(wt)) !== null) { | ||
var block = m[1] | 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||'' }); | |||
while ((lm = lr.exec(block)) !== null) | |||
if (f.id) out.push({ | |||
} | } | ||
return out; | return out; | ||
| Line 448: | Line 464: | ||
positionComposer( $bmComposer ); | positionComposer( $bmComposer ); | ||
$bmComposer.addClass('gra-composer-visible'); | $bmComposer.addClass('gra-composer-visible'); | ||
$bmInput. | setTimeout( function() { $bmInput.focus(); }, isMobile() ? 300 : 0 ); | ||
} | } | ||
| Line 454: | Line 470: | ||
$bmComposer.removeClass('gra-composer-visible'); | $bmComposer.removeClass('gra-composer-visible'); | ||
$bmInput.val(''); | $bmInput.val(''); | ||
_selRange = null; | _selRange = null; _selText = ''; _selRect = null; | ||
} | } | ||
function submitBookmark() { | function submitBookmark() { | ||
var name = $bmInput.val().trim() || ( 'Bookmark ' + (_bookmarks.length+1) ); | var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length + 1)); | ||
var id = uid(); | var id = uid(); | ||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | |||
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() }); | |||
var span = wrapSelection( id, 'gra-bookmark-highlight' ); | |||
if ( span ) { | |||
persistBookmarks(); | persistBookmarks(); | ||
renderBookmarkCards(); | renderBookmarkCards(); | ||
| Line 482: | Line 488: | ||
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) { | ||
var parent = span.parentNode; | var parent = span.parentNode; | ||
| Line 489: | Line 494: | ||
parent.removeChild(span); | parent.removeChild(span); | ||
} | } | ||
persistBookmarks(); | persistBookmarks(); renderBookmarkCards(); | ||
} | } | ||
| Line 496: | Line 500: | ||
try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){} | try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){} | ||
} | } | ||
function loadBookmarks() { | function loadBookmarks() { | ||
try { | try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){} | ||
} | } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// PANEL | // PANEL | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function openPanel( tab ) { | function openPanel(tab) { | ||
_activeTab = tab || _activeTab; | _activeTab = tab || _activeTab; | ||
switchTab( _activeTab ); | switchTab(_activeTab); | ||
$panel.addClass('gra-panel-open'); | $panel.addClass('gra-panel-open'); | ||
$backdrop.addClass('gra-backdrop-visible'); | $backdrop.addClass('gra-backdrop-visible'); | ||
} | } | ||
function closePanel() { | function closePanel() { | ||
$panel.removeClass('gra-panel-open'); | $panel.removeClass('gra-panel-open'); | ||
$backdrop.removeClass('gra-backdrop-visible'); | $backdrop.removeClass('gra-backdrop-visible'); | ||
} | } | ||
function switchTab(tab) { | |||
function switchTab( tab ) { | |||
_activeTab = tab; | _activeTab = tab; | ||
$tabComments.toggleClass('gra-tab-active', tab==='comments'); | $tabComments.toggleClass('gra-tab-active', tab==='comments'); | ||
| Line 526: | Line 524: | ||
$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') loadComments(function(){ renderCommentCards(); }); | ||
else renderBookmarkCards(); | |||
} | } | ||
| Line 538: | Line 533: | ||
function renderCommentCards() { | function renderCommentCards() { | ||
if ( _comments.length | if (!_comments.length) { | ||
$paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>'); | $paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>'); | ||
return; | return; | ||
| Line 550: | Line 545: | ||
+ '<div class="gra-card-author">' + esc(c.author) + '</div>' | + '<div class="gra-card-author">' + esc(c.author) + '</div>' | ||
+ (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></div>' | ||
+ (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '') | + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '') | ||
+ '<div class="gra-card-text">' + esc(c.text) + '</div>' | + '<div class="gra-card-text">' + esc(c.text) + '</div>' | ||
| Line 560: | Line 554: | ||
function renderBookmarkCards() { | function renderBookmarkCards() { | ||
if ( _bookmarks.length | if (!_bookmarks.length) { | ||
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save | $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save one.</div>'); | ||
return; | return; | ||
} | } | ||
| Line 567: | Line 561: | ||
_bookmarks.slice().reverse().forEach(function(b){ | _bookmarks.slice().reverse().forEach(function(b){ | ||
html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">' | html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">' | ||
+ '<span class="gra-icon gra-icon-bookmark"></span>' | + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>' | ||
+ '<div class="gra-bookmark-info">' | + '<div class="gra-bookmark-info">' | ||
+ '<div class="gra-bookmark-name">' + esc(b.name) + '</div>' | + '<div class="gra-bookmark-name">' + esc(b.name) + '</div>' | ||
+ (b.quote ? '<div class="gra-bookmark-quote">' + esc(b.quote) + '</div>' : '') | + (b.quote ? '<div class="gra-bookmark-quote">' + esc(b.quote) + '</div>' : '') | ||
+ '</div>' | + '</div>' | ||
+ '<button class="gra-bookmark-del" data-del-id="' + esc(b.id) + '" title="Remove | + '<button class="gra-bookmark-del" data-del-id="' + esc(b.id) + '" title="Remove">×</button>' | ||
+ '</div>'; | + '</div>'; | ||
}); | }); | ||
| Line 582: | Line 576: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function scrollToHighlight( id ) { | function scrollToHighlight(id) { | ||
var el = document.querySelector('[data-gra-id="' + id + '"]'); | var el = document.querySelector('[data-gra-id="' + id + '"]'); | ||
if (!el) return; | if (!el) return; | ||
| Line 591: | Line 585: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// EVENT WIRING | // EVENT WIRING (desktop + mobile) | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function wireEvents() { | function wireEvents() { | ||
// ── | // ── Desktop: mouseup / keyup ────────────────────────────────── | ||
$( document ).on('mouseup keyup', function(e){ | $( document ).on('mouseup keyup', function(e){ | ||
// | if ( e.type === 'mouseup' && e.button !== 0 ) return; // left-click only | ||
setTimeout( | 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 ────────────────────────────────── | // ── Click outside → hide FAB ────────────────────────────────── | ||
$( document ).on('mousedown', function(e){ | $( document ).on('mousedown touchstart', function(e){ | ||
var | var t = e.target; | ||
if ( | 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 | // ── FAB buttons ─────────────────────────────────────────────── | ||
$( '#gra-fab-comment' ).on('click', function(e){ | $( '#gra-fab-comment' ).on('click touchend', function(e){ | ||
e.preventDefault(); | e.preventDefault(); e.stopPropagation(); | ||
// Re-capture in case selection was cleared by the tap | |||
if ( !captureSelection() ) return; | if ( !_selRange && !captureSelection() ) return; | ||
openCommentComposer(); | openCommentComposer(); | ||
}); | }); | ||
$( '#gra-fab-bookmark' ).on('click touchend', function(e){ | |||
e.preventDefault(); e.stopPropagation(); | |||
$( '#gra-fab-bookmark' ).on('click', function(e){ | if ( !_selRange && !captureSelection() ) return; | ||
e.preventDefault(); | |||
if ( !captureSelection() ) return; | |||
openBookmarkComposer(); | openBookmarkComposer(); | ||
}); | }); | ||
| Line 638: | Line 646: | ||
// ── Comment composer ────────────────────────────────────────── | // ── Comment composer ────────────────────────────────────────── | ||
$cmpInput.on('input', function(){ | $cmpInput.on('input', function(){ | ||
$cmpSubmit.prop('disabled', !$(this).val().trim() | $cmpSubmit.prop('disabled', !$( this ).val().trim()); | ||
}); | }); | ||
$( '#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') | |||
if (e.key==='Escape') { closeCommentComposer(); hideFab(); } | if (e.key==='Escape') { closeCommentComposer(); hideFab(); } | ||
}); | }); | ||
// ── 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(); } | ||
}); | }); | ||
// ── Panel | // ── Panel ───────────────────────────────────────────────────── | ||
$( '#gra-panel-close' ).on('click', closePanel); | $( '#gra-panel-close' ).on('click', closePanel); | ||
$backdrop.on('click', closePanel); | $backdrop.on('click touchstart', closePanel); | ||
$tabComments.on('click', function(){ switchTab('comments'); }); | $tabComments.on('click', function(){ switchTab('comments'); }); | ||
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | ||
// | // Click comment card → scroll to highlight | ||
$paneComments.on('click', '.gra-comment-card', function(){ | $paneComments.on('click', '.gra-comment-card', function(){ | ||
var id = $(this).attr('data-gra-id'); | var id = $( this ).attr('data-gra-id'); | ||
if (id) scrollToHighlight(id); | if (id) { closePanel(); scrollToHighlight(id); } | ||
}); | }); | ||
// Click bookmark card → scroll | |||
// | |||
$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) { closePanel(); scrollToHighlight(id); } | ||
}); | }); | ||
// Delete bookmark | |||
// | |||
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){ | $paneBookmarks.on('click', '.gra-bookmark-del', function(e){ | ||
e.stopPropagation(); | e.stopPropagation(); | ||
var id = $(this).attr('data-del-id'); | var id = $( this ).attr('data-del-id'); | ||
if (id) deleteBookmark(id); | if (id) deleteBookmark(id); | ||
}); | }); | ||
// | // 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+'"]'); | ||
if ($card.length) { | if ($card.length) { | ||
$card.addClass('gra-card-active'); | $card.addClass('gra-card-active'); | ||
$card[0].scrollIntoView({behavior:'smooth',block:'nearest'}); | $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | ||
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); | setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); | ||
} | } | ||
}, 100); | }, 100); | ||
}); | }); | ||
$( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){ | $( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){ | ||
var id = $(this).attr('data-gra-id'); | var id = $( this ).attr('data-gra-id'); | ||
openPanel('bookmarks'); | openPanel('bookmarks'); | ||
setTimeout(function(){ | setTimeout(function(){ | ||
var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]'); | var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]'); | ||
if ($card.length) $card[0].scrollIntoView({behavior:'smooth',block:'nearest'}); | if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | ||
}, 100); | }, 100); | ||
}); | }); | ||
// | // Escape | ||
$( document ).on('keydown', function(e){ | $( document ).on('keydown', function(e){ | ||
if (e.key | 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 | // 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() { | function restoreBookmarkHighlights() { | ||
_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') | |||
var needle = b.quote.replace(/…$/,'').trim().slice(0,60); | var needle = b.quote.replace(/…$/,'').trim().slice(0,60); | ||
if (!needle) return; | if (!needle) return; | ||
var found = findTextInContent(document.querySelector(CONTENT_SEL), needle); | |||
var found = findTextInContent( | |||
if (found) { | if (found) { | ||
var | 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 { | try { found.surroundContents(sp); } catch(e){} | ||
} | } | ||
}); | }); | ||
} | } | ||
function findTextInContent( root, needle ) { | function findTextInContent(root, needle) { | ||
if (!root) return null; | |||
var text = root.textContent || ''; | var text = root.textContent || ''; | ||
var idx = text.indexOf(needle); | var idx = text.indexOf(needle); | ||
if (idx < 0) return null; | if (idx < 0) return null; | ||
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); | var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); | ||
var pos | var pos = 0, 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 > idx) { | if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; } | ||
var endIdx = idx + needle.length; | var endIdx = idx + needle.length; | ||
if (startNode && pos+len >= endIdx) { | if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; } | ||
pos += len; | pos += len; | ||
} | } | ||
if (!startNode || !endNode) return null; | if (!startNode || !endNode) return null; | ||
var | var r = document.createRange(); | ||
r.setStart(startNode, startOffset); | |||
r.setEnd(endNode, endOffset); | |||
return | return r; | ||
} | } | ||
| Line 794: | Line 796: | ||
loadBookmarks(); | loadBookmarks(); | ||
restoreBookmarkHighlights(); | restoreBookmarkHighlights(); | ||
restoreCommentHighlights(); | |||
loadComments(function(){ | loadComments(function(){}); // pre-load comments in background | ||
}); | |||
} ); | |||
}() ); | }() ); | ||
Latest revision as of 18:48, 29 April 2026
/**
* 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="Comment / Bookmark">',
' <button class="gra-fab-btn" id="gra-fab-comment" type="button" aria-label="Add comment">',
' <span class="gra-icon gra-icon-comment" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">Comment</span>',
' </button>',
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">Bookmark</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="Your name (optional)" autocomplete="name">',
' </div>',
].join('');
$cmpComposer = $( [
'<div class="gra-composer" id="gra-cmp-composer" role="dialog" aria-label="Add comment">',
' <div class="gra-composer-user">',
' <div class="gra-avatar" id="gra-cmp-avatar">' + esc(currentUser ? userInitial : '?') + '</div>',
' <div class="gra-composer-uname">' + esc(currentUser || 'Guest') + '</div>',
' </div>',
nameRow,
' <textarea class="gra-composer-input" id="gra-cmp-input"',
' placeholder="Write a comment…" 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 ─────────────────────────────────────────────
$bmComposer = $( [
'<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
' <div class="gra-bm-composer-label">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' Save bookmark',
' </div>',
' <input class="gra-composer-input" id="gra-bm-input"',
' type="text" placeholder="Name this bookmark…" 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" role="complementary" aria-label="Comments">',
' <div id="gra-panel-head">',
' <div id="gra-panel-title"></div>',
' <button id="gra-panel-close" title="Close">✕</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> Comments',
' </button>',
' <button class="gra-tab" id="gra-tab-bookmarks">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></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" aria-hidden="true"></div>' );
$( 'body' ).append( $backdrop );
// Toggle FAB (bottom-right, persistent)
var $toggle = $( [
'<button id="gra-toggle" aria-label="Comments">',
' <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() : '' )
|| 'Guest';
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, id );
notifyByEmail( id, author, quote, text, ts );
renderCommentCards();
closeCommentComposer();
openPanel('comments');
}
function saveCommentToWiki( id, author, quote, text, ts, anchorId ) {
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:'Comment — ' + author,
bot:0,
}).then(function(){
// Silently notify admin by appending a compact notice to their Talk page.
// MediaWiki Echo picks this up and sends an email to whoever watches that page.
// This is completely invisible to the commenter — no browser hijack.
var adminTalk = 'User_talk:Chandrashekars';
var artPath = (mw.config.get('wgArticlePath')||'/wiki/$1');
var link = window.location.origin
+ artPath.replace('$1', pageTitle)
+ (anchorId ? '#' + anchorId : '');
var notice = '\n<!-- grantha-comment-notify -->\n'
+ '; [[' + pageTitle.replace(/_/g,' ') + ']] — ' + author + '\n'
+ ': ' + quote.slice(0,80) + '\n'
+ ': ' + link + '\n';
new mw.Api().postWithEditToken({
action:'edit', title:adminTalk, section:'new',
sectiontitle:'New comment on ' + pageTitle.replace(/_/g,' '),
text:notice,
summary:'Comment notification',
bot:1, /* bot=1 suppresses Echo "someone edited your talk page" popup
but still triggers email watchlist notifications */
}).catch(function(){}); /* silent — never block the comment flow */
}).catch(function(){});
}).catch(function(){});
}
function notifyByEmail( anchorId, author, quote, text, ts ) {
/* Notification is handled server-side by MediaWiki's Echo extension
* when saveCommentToWiki() edits the Talk page — no client-side
* mailto needed. A mailto would abruptly open the user's mail app
* mid-session which breaks the commenting flow entirely. */
}
// ── 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() || ('Bookmark ' + (_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">No comments yet.<br>Select text and click 💬 to add one.</div>');
return;
}
var html = '';
_comments.slice().reverse().forEach(function(c){
html += '<div class="gra-comment-card" data-gra-id="' + esc(c.id) + '">'
+ '<div class="gra-card-header">'
+ '<div class="gra-avatar">' + esc((c.author||'?').charAt(0).toUpperCase()) + '</div>'
+ '<div class="gra-card-meta">'
+ '<div class="gra-card-author">' + esc(c.author) + '</div>'
+ (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
+ '</div></div>'
+ (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
+ '<div class="gra-card-text">' + esc(c.text) + '</div>'
+ '</div>';
});
$paneComments.html(html);
}
function renderBookmarkCards() {
if (!_bookmarks.length) {
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save one.</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="Remove">×</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(){}); // pre-load comments in background
});
}() );