MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab) | ||
*/ | */ | ||
| Line 42: | Line 15: | ||
var currentUserEmail = ''; | var currentUserEmail = ''; | ||
// FIXED: was `new mw.Api()` called directly and synchronously here — | |||
// at this point in MediaWiki:Common.js's own load lifecycle, | |||
// mediawiki.api (the module that provides the REAL mw.Api | |||
// constructor) is not guaranteed to have finished loading yet, and | |||
// calling the constructor before it has throws "mw.Api is not a | |||
// constructor" synchronously. Confirmed directly via the real error | |||
// trace, and confirmed this is pre-existing code, not anything | |||
// introduced by the more recent Ullekha/tab changes. Uncaught here | |||
// (the .catch below only ever handled promise rejections, never a | |||
// synchronous throw from the constructor call itself), this would | |||
// halt the REST of this script's own execution — meaning later code | |||
// in this same file (the tab-building logic) may never run at all on | |||
// FIXED | // whatever page load hits this race. mw.loader.using() guarantees the | ||
// | // module is actually ready before mw.Api is ever touched, regardless | ||
// | // of load timing. Verified the exact failure mode and the fix | ||
// calling the constructor | // directly before applying here. | ||
// | |||
// | |||
// | |||
// | |||
// | |||
// | |||
// | |||
// | |||
// the | |||
if ( currentUser && window.mw ) { | if ( currentUser && window.mw ) { | ||
api | mw.loader.using( 'mediawiki.api' ).then( function () { | ||
return | return new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 }); | ||
} ).then( function ( data ) { | } ).then( function (data) { | ||
var info = data && data.query && data.query.userinfo; | var info = data && data.query && data.query.userinfo; | ||
if ( info && info.email ) currentUserEmail = info.email; | if ( info && info.email ) currentUserEmail = info.email; | ||
| Line 285: | Line 76: | ||
var $bmComposer, $bmInput, $bmSubmit; | var $bmComposer, $bmInput, $bmSubmit; | ||
var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | ||
// CHANGED: added $tabFootnotes / $paneFootnotes alongside the existing | |||
// Notes/Bookmarks tab+pane refs, for the Footnotes tab. REMOVED: the | |||
// earlier $tabSandarbha/$paneSandarbha — that feature was replaced by | |||
// the real Ullekha citation-index mechanism (see quickedit.js), which | |||
// works entirely through common.js's own site-wide logic and needs no | |||
// panel integration here at all. | |||
var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes; | var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes; | ||
| Line 610: | Line 407: | ||
var span = wrapSelection(id, 'gra-note-highlight'); | var span = wrapSelection(id, 'gra-note-highlight'); | ||
if (span) span.setAttribute('data-gra-quote', quote); | if (span) span.setAttribute('data-gra-quote', quote); | ||
_notes.push({id:id, ts:ts, quote:quote, text:text}); | |||
persistNotes(); | |||
persistNoteHighlight(id, quote); | |||
renderNoteCards(); | renderNoteCards(); | ||
closeNoteComposer(); | closeNoteComposer(); | ||
| Line 624: | Line 415: | ||
} | } | ||
function | function persistNotes() { | ||
try { localStorage.setItem(NT_LS_KEY, JSON.stringify(_notes)); } catch(e){} | |||
} | |||
function loadNotes() { | |||
try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){} | |||
} | } | ||
| Line 658: | Line 444: | ||
var span = wrapSelection(id, 'gra-bookmark-highlight'); | var span = wrapSelection(id, 'gra-bookmark-highlight'); | ||
if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); } | 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(); | renderBookmarkCards(); | ||
closeBookmarkComposer(); | closeBookmarkComposer(); | ||
| Line 675: | Line 459: | ||
p.removeChild(span); | p.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){} | |||
} | } | ||
| Line 689: | Line 479: | ||
$backdrop.removeClass('gra-backdrop-visible'); | $backdrop.removeClass('gra-backdrop-visible'); | ||
} | } | ||
// CHANGED: added the footnotes case, alongside notes/bookmarks. | |||
function switchTab(tab) { | function switchTab(tab) { | ||
_activeTab = tab; | _activeTab = tab; | ||
| Line 722: | Line 513: | ||
}); | }); | ||
$paneNotes.html(html); | $paneNotes.html(html); | ||
} | |||
function deleteNote(id) { | |||
_notes = _notes.filter(function(n){ return n.id !== id; }); | |||
var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight'); | |||
if (span && span.parentNode) { | |||
var p = span.parentNode; | |||
while (span.firstChild) p.insertBefore(span.firstChild, span); | |||
p.removeChild(span); | |||
} | |||
try { | |||
var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); | |||
s = s.filter(function(h){ return h.id !== id; }); | |||
localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s)); | |||
} catch(e){} | |||
persistNotes(); renderNoteCards(); | |||
} | } | ||
| Line 743: | Line 550: | ||
} | } | ||
// Footnotes aren't personal annotations | // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal | ||
// | // localStorage annotations, they're real saved page content (QuickEdit | ||
// | // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved | ||
// | // HTML). So this reads straight off the live DOM on every open, no | ||
// | // fetch, no storage — verified the scan + both-direction lookup logic | ||
// against realistic saved-footnote HTML before wiring this in (6/6 pass). | |||
function renderFootnoteCards() { | function renderFootnoteCards() { | ||
var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item'); | var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item'); | ||
| Line 760: | Line 568: | ||
var textEl = li.querySelector('.gra-qe-footnote-text'); | var textEl = li.querySelector('.gra-qe-footnote-text'); | ||
var num = numEl ? numEl.textContent.trim() : ''; | var num = numEl ? numEl.textContent.trim() : ''; | ||
// CHANGED: was showing the NUMBER as the card's primary line and the | |||
// footnote's own note text as secondary — nowhere did it show WHICH | |||
// text in the document the footnote actually belongs to, which was | |||
// the whole point of being able to identify one footnote from | |||
// another. Now shows the referenced quote as primary (matching how | |||
// Notes/Bookmarks already identify themselves by quote, not by an | |||
// arbitrary id), with the number as a small prefix and the | |||
// footnote's own note text as secondary. Falls back to the note | |||
// text alone if a footnote predates this change and has no quote | |||
// span at all, rather than showing nothing. | |||
var quote = quoteEl ? quoteEl.textContent : ''; | var quote = quoteEl ? quoteEl.textContent : ''; | ||
var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent); | var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent); | ||
| Line 781: | Line 599: | ||
el.classList.add('gra-hl-active'); | el.classList.add('gra-hl-active'); | ||
setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000); | setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000); | ||
} | } | ||
| Line 974: | Line 770: | ||
$tabNotes.on('click', function(){ switchTab('notes'); }); | $tabNotes.on('click', function(){ switchTab('notes'); }); | ||
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | ||
// NEW: third tab. | |||
$tabFootnotes.on('click', function(){ switchTab('footnotes'); }); | $tabFootnotes.on('click', function(){ switchTab('footnotes'); }); | ||
$paneNotes.on('click', '.gra-note-card', function(e){ | $paneNotes.on('click', '.gra-note-card', function(e){ | ||
if ($(e.target).hasClass('gra-note-del')) return; | if ($(e.target).hasClass('gra-note-del')) return; | ||
| Line 997: | Line 793: | ||
if (id) deleteBookmark(id); | if (id) deleteBookmark(id); | ||
}); | }); | ||
// | // CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's | ||
// plain highlight span, no href/marker element), so | // redesign — plain highlight span, no href/marker element anymore), so | ||
// same scrollToHighlight helper | // this can reuse the EXACT same scrollToHighlight(id) helper Notes/ | ||
// Bookmarks already use below, instead of custom href-parsing logic | |||
// that broke every time the marker format changed. | |||
$paneFootnotes.on('click', '.gra-footnote-card', function(){ | $paneFootnotes.on('click', '.gra-footnote-card', function(){ | ||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
| Line 1,005: | Line 803: | ||
}); | }); | ||
$(CONTENT_SEL).on('click', '.gra-note-highlight', function(){ | $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){ | ||
var id = $(this).attr('data-gra-id'); | |||
openPanel('notes'); | |||
setTimeout(function(){ | |||
var $card = $paneNotes.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(){ | $(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); | |||
}); | }); | ||
// CHANGED: was .gra-qe-footnote-ref a with e.preventDefault() (it used | |||
// to be a real link). Now a plain highlight span, same as notes/ | |||
// bookmarks — no href to prevent, id read directly via data-gra-id, | |||
// and the handler is now identical in shape to the two right above it. | |||
$(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){ | $(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){ | ||
var id = $(this).attr('data-gra-id'); | |||
openPanel('footnotes'); | |||
setTimeout(function(){ | |||
var $card = $paneFootnotes.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); | |||
}); | }); | ||
// | // NEW: cross-document links from QuickEdit's Link picker. No real | ||
// ever saved (MediaWiki's sanitizer escapes <a href> into visible | // href is ever saved (confirmed directly: MediaWiki's sanitizer | ||
// escapes <a href> into visible literal text on this wiki, even for | |||
// a full absolute URL) — the target lives in data-gr-href instead, | |||
// and this handler is what actually performs the navigation. | |||
$(CONTENT_SEL).on('click', '.gr-crosslink', function(){ | $(CONTENT_SEL).on('click', '.gr-crosslink', function(){ | ||
var href = $(this).attr('data-gr-href'); | var href = $(this).attr('data-gr-href'); | ||
if (!href) return; | if (!href) return; | ||
var newWin = window.open(href, '_blank'); | var newWin = window.open(href, '_blank'); | ||
if (newWin) newWin.opener = null; // more reliable cross-browser than window.open's features string | if (newWin) newWin.opener = null; // more reliable cross-browser than relying on window.open's features string | ||
}); | }); | ||
| Line 1,037: | Line 861: | ||
} | } | ||
function persistNoteHighlight(id, quote) { | |||
try { | |||
var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); | |||
s = s.filter(function(h){ return h.id !== id; }); | |||
s.push({id:id, quote:quote}); | |||
localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s)); | |||
} catch(e){} | |||
} | |||
function restoreNoteHighlights() { | function restoreNoteHighlights() { | ||
var s = []; | |||
if (! | try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){} | ||
if (document.querySelector('[data-gra-id="'+ | s.forEach(function(h){ | ||
var needle = | if (!h.quote || !h.id) return; | ||
if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return; | |||
var needle = h.quote.replace(/…$/,'').trim().slice(0,80); | |||
if (!needle) return; | if (!needle) return; | ||
var range = findTextInContent(document.querySelector(CONTENT_SEL), needle); | var range = findTextInContent(document.querySelector(CONTENT_SEL), needle); | ||
| Line 1,052: | Line 882: | ||
var sp = document.createElement('span'); | var sp = document.createElement('span'); | ||
sp.className = 'gra-note-highlight'; | sp.className = 'gra-note-highlight'; | ||
sp.setAttribute('data-gra-id', | sp.setAttribute('data-gra-id', h.id); | ||
try { range.surroundContents(sp); } catch(e){} | try { range.surroundContents(sp); } catch(e){} | ||
}); | }); | ||
| Line 1,095: | Line 924: | ||
return r; | return r; | ||
} catch(e){ return null; } | } catch(e){ return null; } | ||
} | |||
// NEW: defensive fix for a reported gap — clicking a footnote (or | |||
// bookmark) card in the panel correctly scrolled to the right spot | |||
// (confirmed directly: both the inline marker and the list item share | |||
// the exact same data-gra-id, and scrollToHighlight's own logic is | |||
// fully generic, not specific to notes at all), but the visual | |||
// "flash" highlight wasn't visible. Best-reasoned explanation, not | |||
// confirmed against the actual site stylesheet (not available here): | |||
// .gra-hl-active likely has CSS specific to .gra-note-highlight / | |||
// .gra-bookmark-highlight but no matching rule for | |||
// .gra-qe-footnote-highlight. This rule is unconditional — the flash | |||
// renders the same way regardless of which of the three classes it's | |||
// combined with — so it's safe even if the actual cause turns out to | |||
// be something else. | |||
function injectAnnotationCss() { | |||
if (document.getElementById('gra-hl-active-css')) return; | |||
var css = [ | |||
'.gra-hl-active{background:#fff176!important;border-radius:2px;box-shadow:0 0 0 2px #ffb300!important;transition:background 0.3s,box-shadow 0.3s;}', | |||
].join('\n'); | |||
var style = document.createElement('style'); | |||
style.id = 'gra-hl-active-css'; | |||
style.textContent = css; | |||
document.head.appendChild(style); | |||
} | } | ||
| Line 1,102: | Line 955: | ||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | ||
}); | }); | ||
injectAnnotationCss(); | |||
buildDom(); | buildDom(); | ||
wireEvents(); | wireEvents(); | ||
loadNotes(); | |||
loadBookmarks(); | |||
setTimeout(function(){ | |||
try { restoreNoteHighlights(); } catch(e){} | |||
try { restoreBookmarkHighlights(); } catch(e){} | |||
}, 500); | |||
} | |||
}); | }); | ||
}() ); | }() ); | ||