MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| (19 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback (v6 + Strategy B) | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab) | ||
*/ | */ | ||
| Line 10: | Line 10: | ||
var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
var NT_LS_KEY = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | var NT_LS_KEY = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
// Writes that have not reached the server yet. Scoped per user and per | |||
// page where it is built: the keys above are per page, and a shared | |||
// machine has more than one user. | |||
var Q_LS_KEY = 'grantha_q_v1'; | |||
var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | ||
var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; | var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; | ||
| Line 15: | Line 19: | ||
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 | |||
// 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 | |||
// directly before applying here. | |||
if ( currentUser && window.mw ) { | if ( currentUser && window.mw ) { | ||
new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 }) | mw.loader.using( 'mediawiki.api' ).then( function () { | ||
return new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 }); | |||
} ).then( function (data) { | |||
var info = data && data.query && data.query.userinfo; | |||
if ( info && info.email ) currentUserEmail = info.email; | |||
} ).catch( function () {} ); | |||
} | } | ||
| Line 54: | Line 75: | ||
function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); } | function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); } | ||
function isMobile() { return _mobile; } | function isMobile() { return _mobile; } | ||
// The server caps a note at 8192 *bytes* (MAX_TEXT_BYTES), and Devanagari | |||
// runs three bytes to the character, so the real limit is about 2,700 | |||
// characters — reachable for the kind of thing people write here. Caught | |||
// at the composer, because the alternative is a note that looks saved, | |||
// sits in the panel all session, and is simply absent the next time the | |||
// page is opened. | |||
var MAX_NOTE_BYTES = 8192; | |||
function byteLength(s) { | |||
try { return new TextEncoder().encode(s).length; } | |||
catch (e) { return unescape(encodeURIComponent(s)).length; } | |||
} | |||
/* ══════════════════════ Store ══════════════════════ | |||
* Notes and bookmarks live in the wiki database, in gr_annotations, via | |||
* the GranthaAnnotations extension — which is what lets the same notes | |||
* show up in the mobile app. They used to live only in localStorage, so | |||
* they were stranded in one browser. | |||
* | |||
* localStorage does not go away; it changes job. It is now | |||
* · a cache, so a reload paints the panel instantly instead of waiting | |||
* on the network, and a page read offline still shows notes | |||
* · an outbox mirror, so a write made while offline survives a reload | |||
* The database is the record. The browser is a convenience in front of it. | |||
* | |||
* Anonymous readers have no database row to own, and the API refuses them | |||
* outright, so for them localStorage stays the store exactly as before. | |||
* Every write is optimistic: memory and cache update first, the panel | |||
* re-renders immediately, and the request follows. | |||
*/ | |||
var Store = ( function () { | |||
var loggedIn = !!currentUser; | |||
// Rejections the server will never change its mind about. Anything not | |||
// listed here is treated as temporary and retried. | |||
var TERMINAL = { badid: 1, notowner: 1, texttoolong: 1, missingparam: 1 }; | |||
var _api = null; | |||
var flushing = null; // the in-flight flush's promise, or null | |||
var outbox = []; // authoritative in memory; mirrored to LS | |||
// mw.Api only exists once mediawiki.api has loaded, and asking for it | |||
// sooner throws. Everything that talks to the server waits on this, so a | |||
// load failure degrades to cache-and-queue instead of throwing. | |||
function ready() { | |||
if ( !window.mw || !mw.loader ) return $.Deferred().reject().promise(); | |||
return mw.loader.using( 'mediawiki.api' ).then( function () { | |||
if ( !_api ) _api = new mw.Api(); | |||
return _api; | |||
} ); | |||
} | |||
function ls( k, v ) { | |||
try { | |||
if ( v === undefined ) return localStorage.getItem( k ); | |||
localStorage.setItem( k, v ); | |||
return true; | |||
} catch ( e ) { return null; } | |||
} | |||
function readJson( k, fallback ) { | |||
try { return JSON.parse( ls( k ) || '' ) || fallback; } catch ( e ) { return fallback; } | |||
} | |||
function whoami() { return currentUser || null; } | |||
/* ── Outbox ────────────────────────────────────────────────────── | |||
* Held in memory and mirrored to localStorage, not held *in* it. A | |||
* browser that refuses to store — private mode, quota, policy — used to | |||
* mean the write was never sent at all, because the send re-read the | |||
* queue it had just failed to write. Now storage only decides whether an | |||
* unsent write survives a reload. | |||
* | |||
* Keyed per user AND per page: the cache keys have always been per page, | |||
* so a queue scoped only by user cannot describe them. */ | |||
function qKey() { return Q_LS_KEY + '_' + ( currentUser || 'anon' ) + '_' + pageTitle; } | |||
function mirror() { ls( qKey(), JSON.stringify( outbox ) ); } | |||
function enqueue( op ) { | |||
// One pending operation per annotation. A note edited twice before the | |||
// network returns should send its final state once, not both states in | |||
// order, and a delete supersedes any save still waiting behind it. | |||
outbox = outbox.filter( function ( x ) { return x.id !== op.id; } ); | |||
outbox.push( op ); | |||
mirror(); | |||
} | |||
// Removes the exact operation that was sent, by identity. Matching on | |||
// id and op instead would let a completing send delete a *newer* | |||
// operation for the same annotation that replaced it mid-flight. | |||
function dequeue( op ) { | |||
var i = outbox.indexOf( op ); | |||
if ( i === -1 ) return; | |||
outbox.splice( i, 1 ); | |||
mirror(); | |||
} | |||
function send( op ) { | |||
var params = { action: 'granthaannotationedit', op: op.op, id: op.id, format: 'json' }; | |||
if ( op.op === 'save' ) { | |||
params.page = op.page; | |||
params.type = op.type; | |||
params.quote = op.quote || ''; | |||
params.text = op.text || ''; | |||
if ( op.ts ) params.ts = op.ts; | |||
} | |||
return ready().then( function ( a ) { return a.postWithToken( 'csrf', params ); } ); | |||
} | |||
/* Drain the outbox oldest-first, stopping at the first failure so | |||
* ordering is preserved and a server that is down is not hammered. */ | |||
function flush() { | |||
if ( !loggedIn ) return $.Deferred().resolve().promise(); | |||
// Return the flush already running rather than a fresh resolved | |||
// promise: load() waits on this before reading, and handing it an | |||
// instantly-resolved promise would let the read race the writes. | |||
if ( flushing ) return flushing; | |||
if ( !outbox.length ) return $.Deferred().resolve().promise(); | |||
var d = $.Deferred(); | |||
flushing = d.promise(); | |||
( function step() { | |||
if ( !outbox.length ) { flushing = null; d.resolve(); return; } | |||
var op = outbox[ 0 ]; | |||
send( op ).done( function () { | |||
dequeue( op ); | |||
// Guard against a queue that cannot shrink, which would loop | |||
// re-POSTing the same row for ever. Checked by identity, not by | |||
// length: a note written while this request was in flight makes | |||
// the queue longer, and comparing lengths stopped the drain with | |||
// that newer write still unsent. | |||
if ( outbox.indexOf( op ) !== -1 ) { flushing = null; d.resolve(); return; } | |||
step(); | |||
} ).fail( function ( code ) { | |||
// Verdicts the server will never change its mind about. Left at | |||
// the head of the queue they block every later write on this page | |||
// for ever, so they are dropped and the drain carries on. | |||
if ( TERMINAL[ code ] ) { | |||
dequeue( op ); | |||
step(); | |||
return; | |||
} | |||
// Anything else — offline, 5xx, session expired — is temporary. | |||
// Stop here so ordering is kept and a struggling server is not | |||
// hammered; the next write, `online`, or page load retries. | |||
flushing = null; | |||
d.resolve(); | |||
} ); | |||
}() ); | |||
return flushing; | |||
} | |||
function write( op ) { | |||
if ( !loggedIn ) return; // nothing to send, and nothing to accumulate | |||
enqueue( op ); | |||
flush(); | |||
} | |||
/* ── public ────────────────────────────────────────────────────── */ | |||
/* What has been written locally since the last answer from the server. | |||
* The outbox alone is not enough to reconcile with: a note written while | |||
* the initial read was in flight may well have finished its own POST | |||
* first, and would then be gone from the outbox by the time the older | |||
* answer arrives — which would take it off the screen and out of the | |||
* cache. These two maps outlive the outbox entry and are cleared only | |||
* when a read has actually been folded in. */ | |||
var localSaves = {}, localDeletes = {}; | |||
function save( type, rec ) { | |||
localSaves[ rec.id ] = { type: type, rec: rec }; | |||
delete localDeletes[ rec.id ]; | |||
write( { | |||
op: 'save', id: rec.id, page: pageTitle, type: type, | |||
quote: rec.quote || '', text: type === 'bookmark' ? rec.name : rec.text, | |||
ts: rec.ts | |||
} ); | |||
} | |||
function remove( id ) { | |||
localDeletes[ id ] = true; | |||
delete localSaves[ id ]; | |||
write( { op: 'delete', id: id } ); | |||
} | |||
/* Cache what is on screen, so the next load of this page paints from it | |||
* before the network answers. | |||
* | |||
* The cache records whose it is, for both signed-in and anonymous | |||
* readers. The keys are per page, not per user, and always have been, so | |||
* without an owner two people sharing a machine would see each other's | |||
* notes — and worse, the next person to sign in would have the previous | |||
* one's notes uploaded into their account by migrate(). Recording null | |||
* for "nobody was signed in" is what makes those two cases tellable | |||
* apart from genuinely pre-upgrade data. */ | |||
function cache() { | |||
ls( NT_LS_KEY, JSON.stringify( { u: whoami(), v: _notes } ) ); | |||
ls( BM_LS_KEY, JSON.stringify( { u: whoami(), v: _bookmarks } ) ); | |||
} | |||
function cached( key ) { | |||
var raw = readJson( key, null ); | |||
if ( !raw ) return []; | |||
// A bare array predates this version; only migrate() may claim it. | |||
if ( Array.isArray( raw ) ) return []; | |||
return ( raw.u === whoami() && Array.isArray( raw.v ) ) ? raw.v : []; | |||
} | |||
function fromCache() { | |||
_notes = cached( NT_LS_KEY ); | |||
_bookmarks = cached( BM_LS_KEY ); | |||
} | |||
/* ── Claiming ──────────────────────────────────────────────────── | |||
* Hands over whatever this browser holds with no owner: the bare array | |||
* the old gadget wrote, and the anonymous cache this one writes. Both | |||
* mean "notes taken on this browser with nobody signed in", which is | |||
* what a reader who browsed logged out and then registered has. | |||
* | |||
* There is no "already migrated" flag, deliberately. A flag has to be | |||
* scoped, and every scoping is wrong somewhere: per user alone marked | |||
* every page but the first as done; per user and page was set on the | |||
* first visit even when there was nothing to hand over, so notes taken | |||
* anonymously *afterwards* could never be claimed and were overwritten | |||
* on the next sign-in. | |||
* | |||
* What actually marks the job done is cache(), which replaces the | |||
* ownerless value under the same key with an owned one. After a claim | |||
* there is nothing left to claim, so a note deleted later on another | |||
* device cannot be resurrected by a stale array — and if the browser | |||
* refuses to store, re-claiming is harmless anyway, because the ids are | |||
* unchanged and the server's save is an upsert. */ | |||
/* What a signing-in user may claim as their own: the bare array the old | |||
* gadget wrote, and the anonymous cache this one writes. Both mean "this | |||
* browser's notes, with nobody signed in" — which is what someone who | |||
* read logged out and then registered has. Without the second case, | |||
* signing in read them as unclaimable, then overwrote them with the | |||
* server's empty answer and they were gone for good. */ | |||
function legacy( key ) { | |||
var raw = readJson( key, null ); | |||
if ( Array.isArray( raw ) ) return raw; | |||
if ( raw && raw.u === null && Array.isArray( raw.v ) ) return raw.v; | |||
return []; | |||
} | |||
function migrate() { | |||
if ( !loggedIn ) return false; | |||
var found = false; | |||
legacy( NT_LS_KEY ).forEach( function ( n ) { | |||
if ( !n || !n.id ) return; | |||
// The old gadget imposed no length limit, so a note from before the | |||
// upgrade can be past what the server accepts. Left in, it would sit | |||
// at the head of the queue; dropped here, it stays on screen and in | |||
// the cache, just not synced. | |||
if ( byteLength( n.text || '' ) > MAX_NOTE_BYTES ) return; | |||
found = true; | |||
enqueue( { op: 'save', id: n.id, page: pageTitle, type: 'note', | |||
quote: n.quote || '', text: n.text || '', ts: n.ts } ); | |||
} ); | |||
legacy( BM_LS_KEY ).forEach( function ( b ) { | |||
if ( !b || !b.id ) return; | |||
found = true; | |||
enqueue( { op: 'save', id: b.id, page: pageTitle, type: 'bookmark', | |||
quote: b.quote || '', text: b.name || '', ts: b.ts } ); | |||
} ); | |||
return found; | |||
} | |||
/* ── Reconciliation ────────────────────────────────────────────── | |||
* The server's answer describes the moment the request was sent. A note | |||
* written while it was in the air, or a delete, is in the outbox and not | |||
* in that answer — replacing state wholesale would take it off the | |||
* screen and out of the cache, and the reader would watch their note | |||
* disappear. So anything still pending is re-applied on top. */ | |||
function reconcile( notes, marks ) { | |||
function apply( list, type ) { | |||
var out = list.filter( function ( r ) { | |||
return !localDeletes[ r.id ] && !localSaves[ r.id ]; | |||
} ); | |||
Object.keys( localSaves ).forEach( function ( id ) { | |||
var s = localSaves[ id ]; | |||
if ( s.type === type ) out.push( s.rec ); | |||
} ); | |||
return out; | |||
} | |||
return { notes: apply( notes, 'note' ), marks: apply( marks, 'bookmark' ) }; | |||
} | |||
/* Fill _notes and _bookmarks. The cache is in place synchronously so the | |||
* first paint is instant; the server's answer lands later. */ | |||
function load() { | |||
outbox = readJson( qKey(), [] ); | |||
if ( !Array.isArray( outbox ) ) outbox = []; | |||
if ( !loggedIn ) { | |||
// localStorage is the store, not a cache. Accept the owner-stamped | |||
// shape and, once, the bare array the old gadget wrote. | |||
_notes = cached( NT_LS_KEY ); | |||
_bookmarks = cached( BM_LS_KEY ); | |||
if ( !_notes.length ) _notes = legacy( NT_LS_KEY ); | |||
if ( !_bookmarks.length ) _bookmarks = legacy( BM_LS_KEY ); | |||
return $.Deferred().resolve( false ).promise(); | |||
} | |||
if ( migrate() ) { | |||
// Show what this browser already had straight away. migrate() has | |||
// just claimed it for whoever is signed in now, so what is on screen | |||
// and what is being sent up are the same set — and a first run that | |||
// happens to be offline still shows the reader their notes rather | |||
// than an empty panel. | |||
_notes = legacy( NT_LS_KEY ); | |||
_bookmarks = legacy( BM_LS_KEY ); | |||
cache(); | |||
} else { | |||
fromCache(); | |||
} | |||
var d = $.Deferred(); | |||
var rows = []; | |||
// 500 is the server's maximum page size, and it orders newest first — | |||
// so a reader with more than that on one page would silently lose | |||
// their *oldest* annotations from the panel and from the cache. | |||
// Follow the continuation until it stops rather than assuming nobody | |||
// will get there. | |||
function fetchFrom( offset ) { | |||
return ready().then( function ( a ) { | |||
var params = { | |||
action: 'granthaannotations', page: pageTitle, | |||
limit: 500, formatversion: 2, format: 'json' | |||
}; | |||
if ( offset ) params[ 'continue' ] = offset; | |||
return a.get( params ); | |||
} ).then( function ( data ) { | |||
var block = data && data.granthaannotations; | |||
var got = block && block.annotations; | |||
if ( Array.isArray( got ) ) rows = rows.concat( got ); | |||
// The module reports its own continuation, inside its own result. | |||
var next = block && block[ 'continue' ]; | |||
// Guard against a server that keeps handing back the same offset. | |||
if ( next !== undefined && next !== null && String( next ) !== String( offset ) && | |||
rows.length < 5000 ) { | |||
return fetchFrom( next ); | |||
} | |||
return rows; | |||
} ); | |||
} | |||
flush().always( function () { | |||
fetchFrom( null ).done( function () { | |||
var notes = [], marks = []; | |||
rows.forEach( function ( r ) { | |||
if ( r.type === 'bookmark' ) { | |||
marks.push( { id: r.id, name: r.text, quote: r.quote, ts: r.ts } ); | |||
} else { | |||
notes.push( { id: r.id, ts: r.ts, quote: r.quote, text: r.text } ); | |||
} | |||
} ); | |||
// The server returns newest first; the panel reverses for display, | |||
// so hold them oldest-first the way the local arrays always were. | |||
var merged = reconcile( notes.reverse(), marks.reverse() ); | |||
_notes = merged.notes; | |||
_bookmarks = merged.marks; | |||
// Folded in, so the local record of what has changed since can | |||
// start again. Anything still unsent stays in the outbox. | |||
localSaves = {}; localDeletes = {}; | |||
cache(); | |||
d.resolve( true ); | |||
} ).fail( function () { | |||
// Offline, or the extension is not installed. The cache already | |||
// loaded, and anything written meanwhile is queued. | |||
d.resolve( false ); | |||
} ); | |||
} ); | |||
return d.promise(); | |||
} | |||
if ( loggedIn && window.addEventListener ) { | |||
window.addEventListener( 'online', function () { flush(); } ); | |||
} | |||
return { load: load, save: save, remove: remove, cache: cache, flush: flush, | |||
isRemote: function () { return loggedIn; } }; | |||
}() ); | |||
var $fab, $mobileBar, $panel, $backdrop; | var $fab, $mobileBar, $panel, $backdrop; | ||
| Line 59: | Line 458: | ||
var $bmComposer, $bmInput, $bmSubmit; | var $bmComposer, $bmInput, $bmSubmit; | ||
var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | ||
var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks; | // 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; | |||
function buildDom() { | function buildDom() { | ||
| Line 81: | Line 486: | ||
' </button>', | ' </button>', | ||
' <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">', | ' <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">', | ||
' <span class="gra-icon gra-icon-dismiss" aria-hidden="true"> | ' <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>', | ||
' <span class="gra-fab-btn-label">Close</span>', | ' <span class="gra-fab-btn-label">Close</span>', | ||
' </button>', | ' </button>', | ||
| Line 163: | Line 568: | ||
' <button class="gra-tab" id="gra-tab-bookmarks">', | ' <button class="gra-tab" id="gra-tab-bookmarks">', | ||
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', | ||
' </button>', | |||
' <button class="gra-tab" id="gra-tab-footnotes">', | |||
' <span class="gra-icon gra-icon-footnote" aria-hidden="true"></span> Footnotes', | |||
' </button>', | ' </button>', | ||
' </div>', | ' </div>', | ||
| Line 168: | Line 576: | ||
' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', | ' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', | ||
' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ||
' <div class="gra-pane" id="gra-pane-footnotes"></div>', | |||
' </div>', | ' </div>', | ||
'</div>', | '</div>', | ||
| Line 179: | Line 588: | ||
'<button id="gra-toggle" aria-label="Notes">', | '<button id="gra-toggle" aria-label="Notes">', | ||
' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>', | ' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>', | ||
'</button>', | '</button>', | ||
].join('') ); | ].join('') ); | ||
| Line 190: | Line 598: | ||
$tabNotes = $('#gra-tab-notes'); | $tabNotes = $('#gra-tab-notes'); | ||
$tabBookmarks = $('#gra-tab-bookmarks'); | $tabBookmarks = $('#gra-tab-bookmarks'); | ||
$tabFootnotes = $('#gra-tab-footnotes'); | |||
$paneNotes = $('#gra-pane-notes'); | $paneNotes = $('#gra-pane-notes'); | ||
$paneBookmarks= $('#gra-pane-bookmarks'); | $paneBookmarks= $('#gra-pane-bookmarks'); | ||
$paneFootnotes= $('#gra-pane-footnotes'); | |||
$ntInput = $('#gra-nt-input'); | $ntInput = $('#gra-nt-input'); | ||
$ntSubmit = $('#gra-nt-submit'); | $ntSubmit = $('#gra-nt-submit'); | ||
| Line 217: | Line 627: | ||
document.querySelector('.se-outer'); | document.querySelector('.se-outer'); | ||
if ( _editorEl && _editorEl.contains(ancestor) ) return false; | if ( _editorEl && _editorEl.contains(ancestor) ) return false; | ||
if ( document.body.classList.contains('gra-editing') ) return false; | |||
_selText = text; | _selText = text; | ||
_selRect = range.getBoundingClientRect(); | _selRect = range.getBoundingClientRect(); | ||
| Line 247: | Line 658: | ||
var fabW, fabH, top, left; | var fabW, fabH, top, left; | ||
if (_mobile) { | if (_mobile) { | ||
$fab.css({ position: '', top: '', left: '', visibility: '' }) | $fab.css({ position: '', top: '', left: '', visibility: '' }) | ||
.addClass('gra-fab-visible gra-fab-mobile-docked'); | .addClass('gra-fab-visible gra-fab-mobile-docked'); | ||
| Line 375: | Line 782: | ||
var text = $ntInput.val().trim(); | var text = $ntInput.val().trim(); | ||
if (!text) return; | if (!text) return; | ||
if (byteLength(text) > MAX_NOTE_BYTES) { | |||
alert('This note is too long to save — please shorten it.'); | |||
return; | |||
} | |||
var id = uid(); | var id = uid(); | ||
var ts = nowIso(); | var ts = nowIso(); | ||
| Line 381: | Line 792: | ||
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); | ||
var rec = {id:id, ts:ts, quote:quote, text:text}; | |||
_notes.push(rec); | |||
Store.cache(); | |||
renderNoteCards(); | Store.save('note', rec); | ||
renderNoteCards(); updateBadge(); | |||
closeNoteComposer(); | closeNoteComposer(); | ||
openPanel('notes'); | openPanel('notes'); | ||
} | } | ||
function | |||
/* ── SHAREABLE CITATION ──────────────────────────────────────────── | |||
A bookmark is personal, but what it produces is not: a reference in | |||
APA form plus a link that resolves for anyone. | |||
The link cannot point at the bookmark itself. A bookmark wraps the | |||
reader's own selection in a span with an id generated in their | |||
browser; that span is never saved to the page, so #<bookmark-id> | |||
resolves for nobody else. The citation therefore anchors to the | |||
nearest enclosing block that the PUBLISHED page carries an id for - | |||
the same ids copyId.js links to, and the same ones cross-references | |||
across the site already use. | |||
APA 7th, for a work on a website with no date: | |||
Author. (n.d.). Title (Locator). Site. Retrieved D Month Y, from URL | |||
n.d. because these works carry no publication date, and a retrieval | |||
date because a wiki page can change. The block reference is given as | |||
a parenthetical locator after the title, which is where APA puts a | |||
section or part designation. */ | |||
var CITE_BLOCK_SEL = '.verse-block[id], .bhashyam-block[id], .teeka-block[id], ' + | |||
'[data-block-id][id], .gr-apparatus[id], .shloka[id]'; | |||
function citeAnchorFor(node) { | |||
var el = node && node.nodeType === 3 ? node.parentNode : node; | |||
while (el && el !== document.body) { | |||
if (el.matches && el.matches(CITE_BLOCK_SEL)) return el; | |||
el = el.parentNode; | |||
} | |||
return null; | |||
} | |||
// Walks up from the cited block: an interleaved commentary block states | |||
// its own work, otherwise the page's title element does. | |||
// A reference written in English cites the work in IAST, so prefer the | |||
// transliterated forms the library supplies and keep Devanagari as the | |||
// fallback. common.js writes the library's values onto .gr-doc-title as | |||
// soon as they arrive, so reading the DOM here always gets the current | |||
// names without this module knowing the library exists. | |||
function citeSourceFor(el) { | |||
var n = el; | |||
while (n && n !== document.body) { | |||
if (n.getAttribute && n.getAttribute('data-cite-title')) { | |||
return { | |||
author: n.getAttribute('data-cite-author-iast') || | |||
n.getAttribute('data-cite-author') || '', | |||
title: n.getAttribute('data-cite-title-iast') || | |||
n.getAttribute('data-cite-title') || '', | |||
page: n.getAttribute('data-cite-page') || '' | |||
}; | |||
} | |||
n = n.parentNode; | |||
} | |||
var t = document.querySelector('.gr-doc-title'); | |||
return { | |||
author: (t && (t.getAttribute('data-author-iast') || | |||
t.getAttribute('data-author'))) || '', | |||
title: (t && (t.getAttribute('data-doc-title-iast') || | |||
t.getAttribute('data-doc-title') || | |||
t.textContent.trim())) || document.title, | |||
page: (window.mw && mw.config.get('wgPageName')) || '', | |||
scheme: (t && t.getAttribute('data-locator-scheme')) || '' | |||
}; | |||
} | |||
// Any block carrying a library id gets its author/title filled in from | |||
// the library, whether common.js injected it or the page was built with | |||
// it already in place. Doing it here rather than only at injection time | |||
// means a citation never depends on which path put the block on screen. | |||
function hydrateCitationSources(root) { | |||
if (!window.grLibrary) return; | |||
var nodes = (root || document).querySelectorAll('[data-cite-grantha-id]'); | |||
Array.prototype.forEach.call(nodes, function (el) { | |||
if (el.getAttribute('data-cite-hydrated')) return; | |||
var gid = el.getAttribute('data-cite-grantha-id'); | |||
if (!gid) return; | |||
el.setAttribute('data-cite-hydrated', '1'); | |||
window.grLibrary.lookup(gid).then(function (g) { | |||
if (!g) return; | |||
if (g.name) el.setAttribute('data-cite-title', g.name); | |||
if (g.nameIAST) el.setAttribute('data-cite-title-iast', g.nameIAST); | |||
if (g.author) el.setAttribute('data-cite-author', g.author); | |||
if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST); | |||
}); | |||
}); | |||
} | |||
// ── LOCATOR ───────────────────────────────────────────────────────── | |||
// "KKN_C01_S01_V03" is a build artefact, not a reference. A reader | |||
// citing it wants 1.1.3, and ideally to be told that those numbers are | |||
// adhyaya, valli and mantra. The page declares the division names it | |||
// knows (data-locator-scheme); where it declares none we still give the | |||
// dotted form, which is correct for every text - it is only the NAMES | |||
// of the divisions that vary from work to work. | |||
function parseScheme(s) { | |||
var out = {}; | |||
(s || '').split(',').forEach(function (pair) { | |||
var i = pair.indexOf('='); | |||
if (i > 0) out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim(); | |||
}); | |||
return out; | |||
} | |||
// Segments that qualify the passage rather than number it: _B01 is the | |||
// first paragraph of the bhashya ON that verse, and a bare letter with | |||
// no number (Katha's "_I_" for an introduction) marks a position, not a | |||
// division. A NUMBERED segment is always part of the path - in | |||
// RG_C01_I01_V01 the I is a sukta, and reading it as Katha's | |||
// introduction marker turned 1.1.1 into "1.1, Sukta 1". | |||
function isQualifier(k, n) { return k === 'B' || !n; } | |||
function parseLocator(id) { | |||
if (!id) return null; | |||
var segs = String(id).split('_'); | |||
var path = [], qual = []; | |||
for (var i = 1; i < segs.length; i++) { | |||
var m = /^([A-Za-z]+)(\d*)$/.exec(segs[i]); | |||
if (!m) continue; | |||
var k = m[1].toUpperCase(), n = m[2] ? String(parseInt(m[2], 10)) : ''; | |||
(isQualifier(k, n) ? qual : path).push({ k: k, n: n }); | |||
} | |||
if (!path.length && !qual.length) return null; | |||
return { path: path, qual: qual }; | |||
} | } | ||
function | |||
function locatorText(id, scheme) { | |||
var p = parseLocator(id); | |||
if (!p) return { numeric: id || '', spelled: '' }; | |||
var labels = parseScheme(scheme); | |||
var numeric = p.path.map(function (s) { return s.n; }).filter(Boolean).join('.'); | |||
var spelledParts = p.path.map(function (s) { | |||
return labels[s.k] ? labels[s.k] + ' ' + s.n : null; | |||
}); | |||
var spelled = spelledParts.indexOf(null) === -1 ? spelledParts.join(', ') : ''; | |||
p.qual.forEach(function (s) { | |||
var lab = labels[s.k] || | |||
(s.k === 'B' ? 'bhāṣya' : (s.k === 'I' ? 'intro' : s.k)); | |||
var bit = lab + (s.n ? ' ' + s.n : ''); | |||
numeric += (numeric ? ', ' : '') + bit; | |||
if (spelled) spelled += ', ' + bit; | |||
}); | |||
return { numeric: numeric || id, spelled: spelled }; | |||
} | |||
// Version stamp, so it is possible to tell from the browser console | |||
// which build of this gadget a page is actually running. Check with | |||
// window.grAnnotationsVersion | |||
// APA 7 asks for a retrieval date only when the content is designed to | |||
// change and is not archived. These are settled texts with a fixed | |||
// structure, so the date is omitted. Set true to restore it. | |||
var SHOW_RETRIEVAL_DATE = false; | |||
window.grAnnotationsVersion = '2026-09-30a'; | |||
// A citation is worthless if its link doesn't open, so prefer URLs we | |||
// KNOW are good over ones we assemble. | |||
// - Same page as the reader: use the address they're already at. A wiki | |||
// whose served paths differ from wgArticlePath (or that sits behind a | |||
// rewrite) would otherwise yield a link that 404s. | |||
// - Another page (an injected teeka block cites its own subpage): use | |||
// mw.util.getUrl, the same builder every working link on the wiki | |||
// comes from. wgArticlePath is only the last resort. | |||
function citeUrl(pageName, anchorId) { | |||
var here = (window.mw && mw.config.get('wgPageName')) || ''; | |||
var frag = anchorId ? '#' + encodeURIComponent(anchorId) : ''; | |||
var base; | |||
if (!pageName || String(pageName) === String(here)) { | |||
base = location.origin + location.pathname; | |||
} else if (window.mw && mw.util && mw.util.getUrl) { | |||
base = location.origin + mw.util.getUrl(String(pageName)); | |||
} else { | |||
var path = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1'; | |||
base = location.origin + | |||
path.replace('$1', String(pageName).split('/').map(encodeURIComponent).join('/')); | |||
} | |||
return base + frag; | |||
} | |||
var CITE_MONTHS = ['January','February','March','April','May','June', | |||
'July','August','September','October','November','December']; | |||
function retrievedToday() { | |||
var d = new Date(); | |||
return CITE_MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); | |||
} | |||
function siteName() { | |||
return (window.mw && mw.config.get('wgSiteName')) || location.hostname; | |||
} | |||
// APA 7th, webpage on a website (§10.16): | |||
// Author. (n.d.). Title of work. Site Name. Retrieved <date>, from <URL> | |||
// Two rules this has to honour and an earlier draft did not: | |||
// 1. No known author -> the TITLE moves into the author position; you | |||
// never leave a bare "(n.d.)" with nothing in front of it. And when | |||
// the title occupies that slot the site name is not repeated after it. | |||
// 2. A locator (our block id) does NOT belong in the reference entry. | |||
// The parenthetical slot after a title is for edition/format | |||
// descriptors, not section numbers. APA puts locators in the in-text | |||
// citation, so the block id goes there: (Author, n.d., IS_C01_I03). | |||
// The retrieval date is correct here because the page is designed to | |||
// change and we link to the live version rather than a permalink. | |||
// Titles of standalone works are italicised, which only the HTML flavour | |||
// of the clipboard can carry. | |||
// The library records names with their honorifics and, often, a | |||
// parenthetical alias: "Śrīman Madhvācārya (Ānandatīrtha)". A reference | |||
// entry carries the name a reader would look up, so the honorific and | |||
// the alias come off - leaving "Madhvācārya". Only applied to the | |||
// romanised form; the Devanagari is left exactly as the library has it, | |||
// because there the honorific is fused to the name by sandhi | |||
// (श्रीमत् + आनन्दतीर्थः -> श्रीमदानन्दतीर्थः) and cannot be sliced off safely. | |||
var HONORIFIC_RE = /^(?:\u015br\u012b|\u015br\u012bman|\u015br\u012bmat|\u015br\u012bmad|sri|shri|sree)\s+/i; | |||
function citationAuthor(name) { | |||
var n = (name || '').trim(); | |||
if (!/[A-Za-z]/.test(n)) return n; // Devanagari - leave alone | |||
n = n.replace(/\s*\([^)]*\)\s*$/, '').trim(); | |||
for (var i = 0; i < 3 && HONORIFIC_RE.test(n); i++) { | |||
n = n.replace(HONORIFIC_RE, '').trim(); | |||
} | |||
return n || (name || '').trim(); | |||
} | |||
function buildCitation(rec) { | |||
var src = rec.citeSource || {}; | |||
var author = citationAuthor(src.author); | |||
var title = (src.title || '').trim(); | |||
var anchor = rec.anchor || ''; | |||
var site = (siteName() || '').trim(); | |||
var url = citeUrl(src.page, anchor); | |||
var retrieved = retrievedToday(); | |||
var loc = locatorText(anchor, src.scheme); | |||
var head, headHtml, showSite; | |||
if (author) { | |||
head = author + '. (n.d.). ' + title + '.'; | |||
headHtml = esc(author) + '. (n.d.). <i>' + esc(title) + '</i>.'; | |||
showSite = site && site !== author; | |||
} else { | |||
// Title in the author position - italicised there too, and the site | |||
// name is dropped when it would only repeat what the title says. | |||
head = title + '. (n.d.).'; | |||
headHtml = '<i>' + esc(title) + '</i>. (n.d.).'; | |||
showSite = site && site !== title; | |||
} | |||
// APA 7 (section 9.16) asks for a retrieval date only when the content | |||
// is designed to change and is not archived. A wiki page can change, so | |||
// the date is defensible - but these are settled texts, and a reader may | |||
// reasonably prefer the shorter form. Set SHOW_RETRIEVAL_DATE to false | |||
// for: Author. (n.d.). Title. Site. URL | |||
var tail = SHOW_RETRIEVAL_DATE | |||
? ' Retrieved ' + retrieved + ', from ' + url | |||
: ' ' + url; | |||
var reference = head + (showSite ? ' ' + site + '.' : '') + tail; | |||
var referenceHtml = headHtml + (showSite ? ' ' + esc(site) + '.' : '') + ' ' | |||
+ (SHOW_RETRIEVAL_DATE ? 'Retrieved ' + esc(retrieved) + ', from ' : '') | |||
+ '<a href="' + esc(url) + '">' + esc(url) + '</a>'; | |||
// The in-text citation carries the locator, expanded: 1.1.3, not | |||
// KKN_C01_S01_V03. The name is shortened the way an in-text citation | |||
// shortens one - the library's parenthetical alias ("Śrīman | |||
// Madhvācārya (Ānandatīrtha)") belongs in the reference, not here. | |||
var shortName = (author || title).replace(/\s*\([^)]*\)\s*$/, '').trim(); | |||
var inText = '(' + (shortName || author || title) + ', n.d.' | |||
+ (loc.numeric ? ', ' + loc.numeric : '') + ')'; | |||
return { reference: reference, referenceHtml: referenceHtml, | |||
inText: inText, url: url, | |||
locator: loc.numeric, locatorSpelled: loc.spelled }; | |||
} | |||
// html is optional. When given, both flavours go on the clipboard so a | |||
// paste into Word/Docs keeps the italicised title and the live link, | |||
// while a paste into a plain field (or the address bar) gets the text. | |||
function copyToClipboard(text, done, html) { | |||
if (html && window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) { | |||
try { | |||
var item = new ClipboardItem({ | |||
'text/html': new Blob([html], { type: 'text/html' }), | |||
'text/plain': new Blob([text], { type: 'text/plain' }) | |||
}); | |||
navigator.clipboard.write([item]).then(done).catch(function () { | |||
plainCopy(text, done); | |||
}); | |||
return; | |||
} catch (e) { /* fall through */ } | |||
} | |||
plainCopy(text, done); | |||
} | |||
function plainCopy(text, done) { | |||
if (navigator.clipboard && navigator.clipboard.writeText) { | |||
navigator.clipboard.writeText(text).then(done).catch(function () { legacyCopy(text, done); }); | |||
} else { legacyCopy(text, done); } | |||
} | |||
function legacyCopy(text, done) { | |||
var ta = document.createElement('textarea'); | |||
ta.value = text; | |||
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;'; | |||
document.body.appendChild(ta); ta.focus(); ta.select(); | |||
try { document.execCommand('copy'); done && done(); } catch (e) {} | |||
document.body.removeChild(ta); | |||
} | } | ||
| Line 413: | Line 1,121: | ||
function submitBookmark() { | function submitBookmark() { | ||
var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1)); | var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1)); | ||
// A bookmark's name is stored in the same column as a note's text and | |||
// is capped the same way. | |||
if (byteLength(name) > MAX_NOTE_BYTES) { | |||
alert('This bookmark name is too long to save — please shorten it.'); | |||
return; | |||
} | |||
var id = uid(); | var id = uid(); | ||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | ||
if (!_selRange && _selText) reCaptureFromDOM(); | if (!_selRange && _selText) reCaptureFromDOM(); | ||
// Resolve the citation anchor BEFORE wrapping: wrapSelection inserts a | |||
// span of its own, and resolving afterwards would just find that. | |||
var anchorEl = citeAnchorFor(_selRange && _selRange.startContainer); | |||
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); } | ||
if (!anchorEl && span) anchorEl = citeAnchorFor(span); | |||
var rec = {id:id, name:name, quote:quote, ts:nowIso(), | |||
renderBookmarkCards(); | anchor: anchorEl ? (anchorEl.id || anchorEl.getAttribute('data-block-id') || '') : '', | ||
citeSource: citeSourceFor(anchorEl || document.body)}; | |||
_bookmarks.push(rec); | |||
Store.cache(); | |||
Store.save('bookmark', rec); | |||
renderBookmarkCards(); updateBadge(); | |||
closeBookmarkComposer(); | closeBookmarkComposer(); | ||
openPanel('bookmarks'); | openPanel('bookmarks'); | ||
| Line 433: | Line 1,155: | ||
p.removeChild(span); | p.removeChild(span); | ||
} | } | ||
Store.cache(); Store.remove(id); renderBookmarkCards(); updateBadge(); | |||
} | } | ||
| Line 453: | Line 1,168: | ||
$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; | ||
$tabNotes.toggleClass('gra-tab-active', tab==='notes'); | $tabNotes.toggleClass('gra-tab-active', tab==='notes'); | ||
$tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | ||
$tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes'); | |||
$paneNotes.toggleClass('gra-pane-active', tab==='notes'); | $paneNotes.toggleClass('gra-pane-active', tab==='notes'); | ||
$paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | ||
$paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes'); | |||
if (tab==='notes') renderNoteCards(); | if (tab==='notes') renderNoteCards(); | ||
else renderBookmarkCards(); | else if (tab==='bookmarks') renderBookmarkCards(); | ||
else renderFootnoteCards(); | |||
} | } | ||
| Line 472: | Line 1,191: | ||
html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">' | html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">' | ||
+ '<div class="gra-card-header">' | + '<div class="gra-card-header">' | ||
+ '< | + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>' | ||
+ '<div class="gra-card-meta">' | + '<div class="gra-card-meta">' | ||
+ (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '') | + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '') | ||
| Line 493: | Line 1,212: | ||
p.removeChild(span); | p.removeChild(span); | ||
} | } | ||
Store.cache(); Store.remove(id); renderNoteCards(); updateBadge(); | |||
} | } | ||
| Line 508: | Line 1,222: | ||
var html = ''; | var html = ''; | ||
_bookmarks.slice().reverse().forEach(function(b){ | _bookmarks.slice().reverse().forEach(function(b){ | ||
var cite = buildCitation(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" aria-hidden="true"></span>' | + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>' | ||
| Line 513: | Line 1,228: | ||
+ '<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>' : '') | ||
// One button. The reference already carries the work, the | |||
// locator's page and the link, so a separate "copy link" only | |||
// asked the reader to decide something they don't need to. | |||
// The in-text form stays on the card because it is what goes | |||
// in a sentence, but it copies on click rather than earning a | |||
// button of its own. | |||
// Where in the text this sits, spelled out - "Adhyaya 3, | |||
// Mantra 4" rather than MUN_C03_V04. Not part of the citation | |||
// (APA keeps a locator out of the reference entry), so it is | |||
// set apart as a caption and is not included in the copy. | |||
+ (cite.locatorSpelled || cite.locator | |||
? '<div class="gra-bookmark-locator">' | |||
+ esc(cite.locatorSpelled || cite.locator) + '</div>' | |||
: '') | |||
+ '<div class="gra-bookmark-cite">'+cite.referenceHtml+'</div>' | |||
+ '<div class="gra-cite-actions">' | |||
+ '<button type="button" class="gra-cite-copy" data-cite-what="reference"' | |||
+ ' data-cite-id="'+esc(b.id)+'">Copy citation</button>' | |||
+ '</div>' | |||
+ '</div>' | + '</div>' | ||
+ '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>' | + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>' | ||
| Line 518: | Line 1,252: | ||
}); | }); | ||
$paneBookmarks.html(html); | $paneBookmarks.html(html); | ||
} | |||
// 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() { | |||
var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item'); | |||
if (!items.length) { | |||
$paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>'); | |||
return; | |||
} | |||
// CHANGED: grouped by source. A moola page now also carries the notes | |||
// of every commentary shown on it, and flattening them into one list | |||
// put a commentary's notes after the moola's with nothing saying which | |||
// was which - the cards were indistinguishable. Each note is filed | |||
// under the heading of the document it came from: the page's own notes | |||
// first, then one section per commentary, in the order the page shows | |||
// them. common.js wraps injected notes in .gr-teeka-notes-group with a | |||
// .gr-teeka-notes-title, which is where the heading comes from. | |||
function groupOf(li) { | |||
var wrap = li.closest ? li.closest('.gr-teeka-notes-group') : null; | |||
if (!wrap) return null; | |||
var t = wrap.querySelector('.gr-teeka-notes-title'); | |||
return { | |||
slug: wrap.getAttribute('data-teeka-slug') || '', | |||
title: t ? t.textContent.trim() : '' | |||
}; | |||
} | |||
var sections = []; // [{key, title, items: []}] in document order | |||
var byKey = {}; | |||
Array.prototype.forEach.call(items, function (li) { | |||
var g = groupOf(li); | |||
var key = g ? g.slug : ''; | |||
if (!byKey[key]) { | |||
byKey[key] = { key: key, title: g ? g.title : '', items: [] }; | |||
sections.push(byKey[key]); | |||
} | |||
byKey[key].items.push(li); | |||
}); | |||
var html = ''; | |||
sections.forEach(function (sec) { | |||
if (sec.title) { | |||
html += '<div class="gra-footnote-section-title">' + esc(sec.title) + '</div>'; | |||
} else if (sections.length > 1) { | |||
html += '<div class="gra-footnote-section-title gra-footnote-section-own">' | |||
+ esc(pageOwnNotesLabel()) + '</div>'; | |||
} | |||
sec.items.forEach(function (li) { html += footnoteCard(li, sec.title); }); | |||
}); | |||
$paneFootnotes.html(html); | |||
} | |||
// The heading for the page's own notes, shown only when there is at | |||
// least one commentary section to tell them apart from. | |||
function pageOwnNotesLabel() { | |||
var t = document.querySelector('.gr-doc-title'); | |||
return (t && t.textContent.trim()) || 'This page'; | |||
} | |||
function footnoteCard(li, groupTitle) { | |||
var numEl = li.querySelector('.gra-qe-footnote-num'); | |||
var quoteEl = li.querySelector('.gra-qe-footnote-quote'); | |||
var textEl = li.querySelector('.gra-qe-footnote-text'); | |||
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 text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent); | |||
var primary = quote ? (num + ' ' + quote) : (num + ' ' + text); | |||
var id = li.getAttribute('data-gra-id') || ''; | |||
// The commentary's name is repeated on the card itself, not only on | |||
// the section heading: a reader who has scrolled the panel, or who | |||
// arrives at a card by clicking a word in the text, sees the card | |||
// without its heading in view. | |||
return '<div class="gra-bookmark-card gra-footnote-card" data-gra-id="'+esc(id)+'">' | |||
+ '<span class="gra-icon gra-icon-footnote" aria-hidden="true"></span>' | |||
+ '<div class="gra-bookmark-info">' | |||
+ (groupTitle ? '<div class="gra-footnote-source">'+esc(groupTitle)+'</div>' : '') | |||
+ '<div class="gra-bookmark-name">'+esc(primary)+'</div>' | |||
+ (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '') | |||
+ '</div>' | |||
+ '</div>'; | |||
} | } | ||
| Line 697: | Line 1,526: | ||
$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'); }); | |||
$paneNotes.on('click', '.gra-note-card', function(e){ | $paneNotes.on('click', '.gra-note-card', function(e){ | ||
| Line 710: | Line 1,541: | ||
$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; | ||
if ($(e.target).hasClass('gra-cite-copy')) return; | |||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
if (id) { closePanel(); scrollToHighlight(id); } | if (id) { closePanel(); scrollToHighlight(id); } | ||
}); | |||
$paneBookmarks.on('click', '.gra-cite-copy', function(e){ | |||
e.stopPropagation(); | |||
var $btn = $(this), id = $btn.attr('data-cite-id'); | |||
var rec = _bookmarks.filter(function(b){ return b.id === id; })[0]; | |||
if (!rec) return; | |||
var cite = buildCitation(rec); | |||
var what = $btn.attr('data-cite-what') || 'reference'; | |||
// Only the reference carries formatting worth preserving; the in-text | |||
// form and the bare link must stay plain so they survive a paste into | |||
// the address bar. | |||
var text = cite[what] || cite.reference; | |||
var html = (what === 'reference') ? cite.referenceHtml : null; | |||
var was = $btn.text(); | |||
copyToClipboard(text, function(){ | |||
$btn.text('Copied').addClass('gra-cite-copy--done'); | |||
setTimeout(function(){ $btn.text(was).removeClass('gra-cite-copy--done'); }, 1600); | |||
}, html); | |||
}); | }); | ||
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){ | $paneBookmarks.on('click', '.gra-bookmark-del', function(e){ | ||
| Line 717: | Line 1,567: | ||
var id = $(this).attr('data-del-id'); | var id = $(this).attr('data-del-id'); | ||
if (id) deleteBookmark(id); | if (id) deleteBookmark(id); | ||
}); | |||
// CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's | |||
// redesign — plain highlight span, no href/marker element anymore), so | |||
// 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(){ | |||
var id = $(this).attr('data-gra-id'); | |||
if (id) { closePanel(); scrollToHighlight(id); } | |||
}); | }); | ||
| Line 736: | Line 1,595: | ||
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'}); | // CHANGED: was missing the gra-card-active flash entirely (only | ||
// notes and footnotes had it) — a separate inconsistency from | |||
// the CSS gap below. Now matches the other two exactly. | |||
if ($card.length) { | |||
$card.addClass('gra-card-active'); | |||
$card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | |||
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); | |||
} | |||
}, 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(){ | |||
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); | }, 100); | ||
}); | |||
// NEW: cross-document links from QuickEdit's Link picker. No real | |||
// 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(){ | |||
var href = $(this).attr('data-gr-href'); | |||
if (!href) return; | |||
var newWin = window.open(href, '_blank'); | |||
if (newWin) newWin.opener = null; // more reliable cross-browser than relying on window.open's features string | |||
}); | }); | ||
| Line 749: | Line 1,643: | ||
} | } | ||
// Re-anchors note highlights from _notes, the way bookmarks have always | |||
// re-anchored from _bookmarks. This used to read a third store — | |||
// NT_LS_KEY + '_hl' — holding {id, quote} pairs that _notes already had, | |||
// and which nothing kept in step once notes could also arrive from the | |||
// server. The old key is left alone rather than deleted; it is simply no | |||
// longer consulted. | |||
function restoreNoteHighlights() { | function restoreNoteHighlights() { | ||
_notes.forEach(function(h){ | |||
if (!h.quote || !h.id) return; | if (!h.quote || !h.id) return; | ||
if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return; | if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return; | ||
| Line 813: | Line 1,702: | ||
} catch(e){ return null; } | } catch(e){ return null; } | ||
} | } | ||
// REMOVED: injectAnnotationCss(), which JS-injected a defensive | |||
// .gra-card-active/.gra-hl-active rule here as a guess, since the real | |||
// gr_annotations.css wasn't available at the time. Now that it is, | |||
// confirmed precisely: .gra-hl-active already had working CSS for | |||
// notes and footnotes (that JS rule was unneeded there, though it did | |||
// fill a real gap for bookmarks' .gra-hl-active, which had none at | |||
// all) — and .gra-card-active genuinely only had a rule for | |||
// .gra-note-card, nothing for bookmark or footnote cards, confirming | |||
// that diagnosis exactly. The JS version also risked overriding the | |||
// existing, working note-card style with !important. Fixed directly | |||
// in gr_annotations.css instead — see that file. | |||
$(function() { | $(function() { | ||
| Line 821: | Line 1,722: | ||
buildDom(); | buildDom(); | ||
wireEvents(); | wireEvents(); | ||
hydrateCitationSources(); | |||
// Commentary blocks arrive after the page does, so hydrate again when | |||
// they land rather than only once at boot. | |||
window.addEventListener('gr-new-content', function (e) { | |||
hydrateCitationSources(e && e.detail && e.detail.container); | |||
}); | |||
function paint() { | |||
try { restoreNoteHighlights(); } catch(e){} | try { restoreNoteHighlights(); } catch(e){} | ||
try { restoreBookmarkHighlights(); } catch(e){} | try { restoreBookmarkHighlights(); } catch(e){} | ||
}, 500); | if ($panel.hasClass('gra-panel-open')) switchTab(_activeTab); | ||
updateBadge(); | |||
} | |||
// The cache is in place the moment load() returns, so the first paint | |||
// still happens on the old 500 ms timer and feels unchanged. The | |||
// server's answer arrives later and paints again — highlights it adds | |||
// are skipped if already present, so the second pass only fills in what | |||
// the cache did not have: a note made on the phone, or in another | |||
// browser. | |||
var loaded = Store.load(); | |||
setTimeout(paint, 500); | |||
loaded.always(paint); | |||
}); | }); | ||
// Small count on the toggle, so a reader can tell there is something in | |||
// the panel without opening it — worth having now that notes can arrive | |||
// from another device. | |||
// The count badge is gone. It sat on the round toggle, which clips its | |||
// own overflow to stay circular, so the badge was cut off on every side | |||
// and told the reader nothing they could act on. Kept as a no-op so the | |||
// five call sites that report a change still read clearly. | |||
function updateBadge() { } | |||
}() ); | }() ); | ||