MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| (67 intermediate revisions by 2 users not shown) | |||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab) | ||
*/ | */ | ||
| Line 63: | Line 7: | ||
'use strict'; | 'use strict'; | ||
var CONTENT_SEL = '#mw-content-text'; | |||
var | var BM_LS_KEY = 'grantha_bm_' + ( ( 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 | |||
var BM_LS_KEY | // page where it is built: the keys above are per page, and a shared | ||
var | // machine has more than one user. | ||
var pageTitle | var Q_LS_KEY = 'grantha_q_v1'; | ||
var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | |||
var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; | |||
var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?'; | |||
var currentUserEmail = ''; | |||
var currentUser | |||
var userInitial | // 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 ) { | |||
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 () {} ); | |||
} | |||
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; | ||
} | } | ||
var _selRange = null; | |||
var _selText = ''; | |||
var _selRange = null; | var _selRect = null; | ||
var _selText = ''; | var _notes = []; | ||
var _selRect = null; | var _bookmarks = []; | ||
var | var _activeTab = 'notes'; | ||
var _bookmarks = []; | var _selVersion = 0; | ||
var | var _fabSelVer = -1; | ||
var | var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | ||
var _fabTouched = false; // flag to prevent hideActions when tapping fab | |||
function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); } | |||
function uid() { | 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) { | |||
function nowIso() { | |||
function fmtTs( ts ) { | |||
try { | try { | ||
var d = new Date( ts ); | var d = new Date(ts); | ||
return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'}) | return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'}) | ||
+ ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false}); | + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false}); | ||
} 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 _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() { | |||
var $fab, $panel, $backdrop | _notes = cached( NT_LS_KEY ); | ||
var $ | _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 $ntComposer, $ntInput, $ntSubmit; | |||
var $bmComposer, $bmInput, $bmSubmit; | var $bmComposer, $bmInput, $bmSubmit; | ||
var $ | 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; | |||
function buildDom() { | function buildDom() { | ||
$fab = $( [ | $fab = $( [ | ||
'<div id="gra-fab">', | '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">', | ||
' <button class="gra-fab-btn" id="gra-fab- | ' <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Note">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-note" aria-hidden="true"></span>', | ||
' <span class="gra-fab- | ' <span class="gra-fab-btn-label">Note</span>', | ||
' </button>', | |||
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Mark">', | |||
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', | |||
' <span class="gra-fab-btn-label">Mark</span>', | |||
' </button>', | |||
' <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Feedback">', | |||
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | |||
' <span class="gra-fab-btn-label">Feedback</span>', | |||
' </button>', | |||
' <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search">', | |||
' <span class="gra-icon gra-icon-search" aria-hidden="true"></span>', | |||
' <span class="gra-fab-btn-label">Search</span>', | |||
' </button>', | ' </button>', | ||
' <button class="gra-fab-btn" id="gra-fab- | ' <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>', | ||
' <span class="gra-fab- | ' <span class="gra-fab-btn-label">Close</span>', | ||
' </button>', | ' </button>', | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $fab ); | $('body').append($fab); | ||
$mobileBar = $('<div id="gra-mobile-bar"></div>'); | |||
$('body').append($mobileBar); | |||
// | $fbComposer = $( [ | ||
$ | '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">', | ||
'<div class="gra-composer" id="gra- | ' <div class="gra-composer-header">', | ||
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | |||
' <strong>Feedback</strong>', | |||
' <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>', | |||
' </div>', | |||
' <div class="gra-fb-quote-label">Selected text:</div>', | |||
' <div class="gra-fb-quote" id="gra-fb-quote"></div>', | |||
' <div class="gra-fb-field-label">Issue type</div>', | |||
' <select class="gra-fb-select" id="gra-fb-issue">', | |||
' <option value="">— Choose —</option>', | |||
' <option value="wrong_text">Formatting error</option>', | |||
' <option value="reference_issue">Reference issue</option>', | |||
' <option value="spelling_mistake">Spelling mistake</option>', | |||
' <option value="other">Other</option>', | |||
' </select>', | |||
' <div class="gra-fb-field-label">Details (optional)</div>', | |||
' <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>', | |||
' <div class="gra-fb-field-label">Your email (optional)</div>', | |||
' <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">', | |||
' <div class="gra-composer-actions">', | |||
' <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>', | |||
' <button class="gra-btn-submit" id="gra-fb-submit" disabled>Send</button>', | |||
' </div>', | |||
' <div class="gra-fb-status" id="gra-fb-status"></div>', | |||
'</div>', | |||
].join('') ); | |||
$('body').append($fbComposer); | |||
$ntComposer = $( [ | |||
'<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">', | |||
' <div class="gra-composer-user">', | ' <div class="gra-composer-user">', | ||
' <div class="gra | ' <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>', | ||
' <div class="gra-composer- | ' <div class="gra-composer-uname">' + esc(currentUser || 'Notes') + '</div>', | ||
' </div>', | ' </div>', | ||
' <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>', | |||
' <textarea class="gra-composer-input" id="gra- | |||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra- | ' <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>', | ||
' <button class="gra-btn-submit" id="gra- | ' <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>', | ||
' </div>', | ' </div>', | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$('body').append($ntComposer); | |||
$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="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 181: | Line 554: | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $bmComposer ); | $('body').append($bmComposer); | ||
$panel = $( [ | $panel = $( [ | ||
'<div id="gra-panel">', | '<div id="gra-panel" role="complementary" aria-label="Notes">', | ||
' <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- | ' <button class="gra-tab gra-tab-active" id="gra-tab-notes">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes', | ||
' </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 class="gra-tab" id="gra-tab-footnotes">', | |||
' <span class="gra-icon gra-icon-footnote" aria-hidden="true"></span> Footnotes', | |||
' </button>', | ' </button>', | ||
' </div>', | ' </div>', | ||
' <div id="gra-panel-body">', | ' <div id="gra-panel-body">', | ||
' <div class="gra-pane gra-pane-active" id="gra-pane- | ' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', | ||
' <div class="gra-pane" | ' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ||
' <div class="gra-pane" id="gra-pane-footnotes"></div>', | |||
' </div>', | ' </div>', | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( '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 ); | |||
var $toggle = $( [ | var $toggle = $( [ | ||
'<button id="gra-toggle" | '<button id="gra-toggle" aria-label="Notes">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>', | ||
'</button>', | '</button>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $toggle ); | $('body').append($toggle); | ||
$toggle.on( 'click', function() { | $toggle.on('click', function() { | ||
$panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab); | |||
}); | |||
} ); | |||
$('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30)); | |||
$tabNotes = $('#gra-tab-notes'); | |||
$tabBookmarks = $('#gra-tab-bookmarks'); | |||
$tabFootnotes = $('#gra-tab-footnotes'); | |||
$paneNotes = $('#gra-pane-notes'); | |||
$paneBookmarks= $('#gra-pane-bookmarks'); | |||
$ | $paneFootnotes= $('#gra-pane-footnotes'); | ||
$ | $ntInput = $('#gra-nt-input'); | ||
$ | $ntSubmit = $('#gra-nt-submit'); | ||
$ | $bmInput = $('#gra-bm-input'); | ||
$ | $bmSubmit = $('#gra-bm-submit'); | ||
$ | $fbIssueType = $('#gra-fb-issue'); | ||
$bmInput | $fbText = $('#gra-fb-text'); | ||
$bmSubmit = $( '#gra- | $fbEmail = $('#gra-fb-email'); | ||
$fbSubmit = $('#gra-fb-submit'); | |||
$fbQuote = $('#gra-fb-quote'); | |||
} | |||
function captureSelection() { | |||
if ( ! | 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 ancestor = range.commonAncestorContainer; | |||
if (ancestor.nodeType === 3) ancestor = ancestor.parentNode; | |||
if (!ancestor || !contentEl.contains(ancestor)) return false; | |||
var _editorEl = document.getElementById('se-surface') || | |||
document.querySelector('.se-outer'); | |||
if ( _editorEl && _editorEl.contains(ancestor) ) return false; | |||
if ( document.body.classList.contains('gra-editing') ) return false; | |||
_selText = text; | |||
_selRect = range.getBoundingClientRect(); | |||
try { _selRange = range.cloneRange(); } | |||
catch(e){ _selRange = null; } | |||
return true; | |||
} | } | ||
// | function reCaptureFromDOM() { | ||
if (!_selText) return false; | |||
var contentEl = document.querySelector(CONTENT_SEL); | |||
if (!contentEl) return false; | |||
var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim()); | |||
if (!found) return false; | |||
_selRange = found; | |||
return true; | |||
} | |||
function | function tryShowActions() { | ||
if ( | if ($fbComposer && $fbComposer.hasClass('gra-composer-visible')) return; | ||
if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return; | |||
if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return; | |||
if (!captureSelection()) { hideActions(); return; } | |||
_fabSelVer = _selVersion; | |||
showFab(_selRect); | |||
} | |||
function showFab(rect) { | |||
if ( | if (!rect) return; | ||
left | var fabW, fabH, top, left; | ||
if (_mobile) { | |||
$fab.css({ position: '', top: '', left: '', visibility: '' }) | |||
.addClass('gra-fab-visible gra-fab-mobile-docked'); | |||
return; | |||
} | } | ||
// | fabW = 46; fabH = 126; | ||
top = clamp( top, 8, window.innerHeight - fabH - 8 ); | top = rect.top + (rect.height / 2) - (fabH / 2); | ||
left = rect.right + 10; | |||
left = clamp( left, 8, window.innerWidth - fabW - 8 ); | 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'); | $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible'); | ||
} | } | ||
function hideFab() { | function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); } | ||
function hideActions() { hideFab(); } | |||
function wrapSelection(id, cssClass) { | |||
var range = _selRange; | |||
_selRange = null; | |||
if (!range) return null; | |||
try { | |||
if (!document.contains(range.startContainer) || | |||
!document.contains(range.endContainer)) return null; | |||
} catch(e) { return null; } | |||
function makeSpan() { | |||
var sp = document.createElement('span'); | |||
sp.className = cssClass; | |||
sp.setAttribute('data-gra-id', id); | |||
return sp; | |||
} | |||
try { | |||
var span = makeSpan(); | |||
range.surroundContents(span); | |||
if (span.parentNode) return span; | |||
} catch(e) {} | |||
try { | |||
var frag = range.extractContents(); | |||
var sp2 = makeSpan(); | |||
sp2.appendChild(frag); | |||
range.insertNode(sp2); | |||
if (sp2 && sp2.parentNode) return sp2; | |||
} catch(e2) {} | |||
return null; | |||
} | } | ||
function openFeedbackComposer() { | |||
hideActions(); | |||
$fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : '')); | |||
$fbIssueType.val(''); | |||
$fbText.val(''); | |||
$fbSubmit.prop('disabled', true); | |||
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); | |||
if (currentUserEmail) $fbEmail.val(currentUserEmail); | |||
else $fbEmail.val(''); | |||
if (!_mobile) $fbComposer.css({top:'', left:'', transform:''}); | |||
$fbComposer.addClass('gra-composer-visible'); | |||
$backdrop.addClass('gra-backdrop-visible'); | |||
setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0); | |||
} | |||
function | function closeFeedbackComposer() { | ||
$fbComposer.removeClass('gra-composer-visible'); | |||
$backdrop.removeClass('gra-backdrop-visible'); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
} | |||
var | function submitFeedback() { | ||
var | var issueType = $fbIssueType.val(); | ||
if ( !text || | var details = $fbText.val().trim(); | ||
var email = $fbEmail.val().trim(); | |||
var quote = $fbQuote.text(); | |||
if (!issueType) return; | |||
$fbSubmit.prop('disabled', true).text('Sending…'); | |||
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); | |||
var issueLabels = { | |||
wrong_text: 'Formatting error', reference_issue: 'Reference issue', | |||
spelling_mistake: 'Spelling mistake', other: 'Other' | |||
}; | |||
var payload = new FormData(); | |||
payload.append('issue_type', issueLabels[issueType] || issueType); | |||
payload.append('page', pageTitle.replace(/_/g,' ')); | |||
payload.append('url', window.location.href); | |||
payload.append('selected_text', quote); | |||
payload.append('details', details || ''); | |||
payload.append('user_email', email || currentUserEmail || ''); | |||
payload.append('wiki_user', currentUser || 'anonymous'); | |||
fetch('/feedback.php', {method:'POST', body:payload}) | |||
.then(function(r){ return r.json(); }) | |||
.then(function(data){ | |||
if (data && data.ok) showFeedbackSuccess(); | |||
else showFeedbackError(data && data.error ? data.error : 'Could not send.'); | |||
}) | |||
.catch(function(){ showFeedbackError('Network error. Please try again.'); }); | |||
} | |||
function showFeedbackSuccess() { | |||
$fbSubmit.prop('disabled', false).text('Send'); | |||
$('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok'); | |||
setTimeout(closeFeedbackComposer, 2500); | |||
} | |||
function showFeedbackError(msg) { | |||
$fbSubmit.prop('disabled', false).text('Send'); | |||
$('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err'); | |||
} | |||
function openNoteComposer() { | |||
hideActions(); | |||
if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' }); | |||
$ntComposer.addClass('gra-composer-visible'); | |||
$backdrop.addClass('gra-backdrop-visible'); | |||
setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0); | |||
} | } | ||
function closeNoteComposer() { | |||
$ntComposer.removeClass('gra-composer-visible'); | |||
$backdrop.removeClass('gra-backdrop-visible'); | |||
$ntInput.val(''); | |||
$ntSubmit.prop('disabled', true); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
} | |||
function | function submitNote() { | ||
var text = $ntInput.val().trim(); | |||
if (!text) return; | |||
var | if (byteLength(text) > MAX_NOTE_BYTES) { | ||
alert('This note is too long to save — please shorten it.'); | |||
return; | |||
if ( | |||
} | } | ||
var id = uid(); | |||
var ts = nowIso(); | |||
if ( | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | ||
if (!_selRange && _selText) reCaptureFromDOM(); | |||
var span = wrapSelection(id, 'gra-note-highlight'); | |||
if (span) span.setAttribute('data-gra-quote', quote); | |||
var rec = {id:id, ts:ts, quote:quote, text:text}; | |||
_notes.push(rec); | |||
Store.cache(); | |||
Store.save('note', rec); | |||
renderNoteCards(); updateBadge(); | |||
closeNoteComposer(); | |||
openPanel('notes'); | |||
} | } | ||
/* ── 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')) || '' | |||
}; | |||
} | |||
function | // 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); | |||
}); | |||
}); | |||
} | } | ||
function | // ── 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) { | |||
var | 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 locatorText(id, scheme) { | |||
var | 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)) { | |||
var | 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(); | |||
var | 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 | function buildCitation(rec) { | ||
var | var src = rec.citeSource || {}; | ||
var | var author = citationAuthor(src.author); | ||
var | 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; | |||
} | } | ||
return | |||
// 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); | |||
} | |||
function openBookmarkComposer() { | function openBookmarkComposer() { | ||
hideActions(); | |||
if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' }); | |||
$bmComposer.addClass('gra-composer-visible'); | $bmComposer.addClass('gra-composer-visible'); | ||
$ | $backdrop.addClass('gra-backdrop-visible'); | ||
setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0); | |||
} | } | ||
function closeBookmarkComposer() { | function closeBookmarkComposer() { | ||
$bmComposer.removeClass('gra-composer-visible'); | $bmComposer.removeClass('gra-composer-visible'); | ||
$backdrop.removeClass('gra-backdrop-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)); | ||
// 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(); | ||
// Resolve the citation anchor BEFORE wrapping: wrapSelection inserts a | |||
// | // span of its own, and resolving afterwards would just find that. | ||
var span = wrapSelection( id, 'gra-bookmark-highlight' ); | var anchorEl = citeAnchorFor(_selRange && _selRange.startContainer); | ||
if ( span ) { | var span = wrapSelection(id, 'gra-bookmark-highlight'); | ||
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(), | |||
anchor: anchorEl ? (anchorEl.id || anchorEl.getAttribute('data-block-id') || '') : '', | |||
var | citeSource: citeSourceFor(anchorEl || document.body)}; | ||
_bookmarks.push( | _bookmarks.push(rec); | ||
Store.cache(); | |||
renderBookmarkCards(); | Store.save('bookmark', rec); | ||
renderBookmarkCards(); updateBadge(); | |||
closeBookmarkComposer(); | closeBookmarkComposer(); | ||
openPanel('bookmarks'); | openPanel('bookmarks'); | ||
} | } | ||
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 && span.parentNode) { | ||
if (span) { | var p = span.parentNode; | ||
var | while (span.firstChild) p.insertBefore(span.firstChild, span); | ||
while (span.firstChild) | p.removeChild(span); | ||
} | } | ||
Store.cache(); Store.remove(id); renderBookmarkCards(); updateBadge(); | |||
} | } | ||
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'); | ||
} | } | ||
// 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'); | ||
$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'); | |||
$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(); | |||
else if (tab==='bookmarks') renderBookmarkCards(); | |||
else renderFootnoteCards(); | |||
} | } | ||
function renderNoteCards() { | |||
if (!_notes.length) { | |||
$paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>'); | |||
function | |||
if ( | |||
$ | |||
return; | return; | ||
} | } | ||
var html = ''; | var html = ''; | ||
_notes.slice().reverse().forEach(function(n){ | |||
html += '<div class="gra- | 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>' : '') | |||
+ ( | |||
+ '</div>' | + '</div>' | ||
+ '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>' | |||
+ '</div>' | + '</div>' | ||
+ ( | + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '') | ||
+ '<div class="gra-card-text">' + esc( | + '<div class="gra-card-text">'+esc(n.text)+'</div>' | ||
+ '</div>'; | + '</div>'; | ||
}); | }); | ||
$ | $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); | |||
} | |||
Store.cache(); Store.remove(id); renderNoteCards(); updateBadge(); | |||
} | } | ||
function renderBookmarkCards() { | function renderBookmarkCards() { | ||
if ( _bookmarks.length | if (!_bookmarks.length) { | ||
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and | $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>'); | ||
return; | return; | ||
} | } | ||
var html = ''; | var html = ''; | ||
_bookmarks.slice().reverse().forEach(function(b){ | _bookmarks.slice().reverse().forEach(function(b){ | ||
html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">' | var cite = buildCitation(b); | ||
+ '<span class="gra-icon gra-icon-bookmark"></span>' | 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-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>' : '') | ||
// 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 class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>' | ||
+ '</div>'; | + '</div>'; | ||
}); | }); | ||
| Line 658: | Line 1,254: | ||
} | } | ||
// | // 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>'; | |||
} | |||
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; | ||
el.scrollIntoView({ behavior:'smooth', block:'center' }); | el.scrollIntoView({behavior:'smooth', block:'center'}); | ||
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); | ||
} | } | ||
function wireEvents() { | |||
/* Suppress native context menu inside article content (Android/desktop) */ | |||
document.addEventListener('contextmenu', function(e) { | |||
var tag = e.target.tagName; | |||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; | |||
var c = document.querySelector(CONTENT_SEL); | |||
if (c && c.contains(e.target)) e.preventDefault(); | |||
}, { passive: false }); | |||
/ | /* Desktop mouseup */ | ||
$(document).on('mouseup', function(e){ | |||
if (e.button !== 0) return; | |||
$( document ).on('mouseup', function(e){ | if (_mobile) return; | ||
setTimeout(tryShowActions, 20); | |||
if | |||
if ( | |||
setTimeout( | |||
}); | }); | ||
// | /* Separate timers so mobile + desktop never clobber each other */ | ||
// | var _selTimer = null; /* desktop debounce */ | ||
/ | var _mobShowTimer = null; /* mobile show-on-touchend */ | ||
/* Mobile: show fab quickly after finger lifts (selection settled). | |||
180ms feels instant while still letting the range stabilise. */ | |||
document.addEventListener('touchend', function(e) { | |||
if (!_mobile) return; | |||
if ($fab[0] && $fab[0].contains(e.target)) return; | |||
clearTimeout(_mobShowTimer); | |||
_mobShowTimer = setTimeout(function() { | |||
var sel = window.getSelection(); | |||
if (!sel || sel.isCollapsed || !sel.toString().trim()) return; | |||
tryShowActions(); | |||
}, 180); | |||
}, { passive: true }); | |||
/* Mobile: only HIDE the fab when selection is cleared while it's visible. | |||
(Reposition isn't needed now that the bar is docked, and re-running | |||
showFab here was causing the lag/flicker.) */ | |||
document.addEventListener('selectionchange', function() { | |||
if (!_mobile) return; | |||
if (!$fab.hasClass('gra-fab-visible')) return; | |||
var sel = window.getSelection(); | |||
if (!sel || sel.isCollapsed || !sel.toString().trim()) { | |||
clearTimeout(_mobShowTimer); | |||
hideActions(); | |||
} | |||
}); | }); | ||
// | /* selectionchange debounced (desktop only) */ | ||
document.addEventListener('selectionchange', function() { | |||
var | if (_mobile) return; | ||
_selVersion++; | |||
clearTimeout(_selTimer); | |||
} | var v = _selVersion; | ||
_selTimer = setTimeout(function(){ | |||
if (v !== _selVersion) return; | |||
if (_fabSelVer === v) return; | |||
tryShowActions(); | |||
}, 600); | |||
}); | }); | ||
/ | /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */ | ||
$ | $fab[0].addEventListener('touchstart', function(e) { | ||
_fabTouched = true; | |||
/* Don't propagate to document handler */ | |||
e.stopPropagation(); | e.stopPropagation(); | ||
}, { passive: true }); | |||
/* Click outside → hide actions (blocked if fab was touched) */ | |||
$(document).on('mousedown touchstart', function(e){ | |||
if (_fabTouched) { _fabTouched = false; return; } | |||
var t = e.target; | |||
if ($fab[0] && $fab[0].contains(t)) return; | |||
if ($fbComposer[0] && $fbComposer[0].contains(t)) return; | |||
if ($ntComposer[0] && $ntComposer[0].contains(t)) return; | |||
if ($bmComposer[0] && $bmComposer[0].contains(t)) return; | |||
hideActions(); | |||
}); | }); | ||
/ | /* ── FAB buttons — use touchend for mobile, click for desktop ── */ | ||
function fabAction(btnId, action) { | |||
e.stopPropagation(); | var el = document.getElementById(btnId); | ||
if (!el) return; | |||
/* touchend: fires before document touchstart clears _selRange */ | |||
el.addEventListener('touchend', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
if (!_selRange && !reCaptureFromDOM()) return; | |||
action(); | |||
}, { passive: false }); | |||
/* click: for desktop */ | |||
el.addEventListener('click', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
if (!_selRange && !reCaptureFromDOM()) return; | |||
action(); | |||
}); | |||
} | |||
fabAction('gra-fab-note', openNoteComposer); | |||
fabAction('gra-fab-bookmark', openBookmarkComposer); | |||
fabAction('gra-fab-feedback', openFeedbackComposer); | |||
document.getElementById('gra-fab-search').addEventListener('touchend', function(e) { | |||
e.preventDefault(); e.stopPropagation(); | |||
var q = _selText; | |||
hideActions(); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
if (q && window.showSearchDialog) { window.showSearchDialog(q); } | |||
}, { passive: false }); | |||
document.getElementById('gra-fab-search').addEventListener('click', function(e) { | |||
e.preventDefault(); e.stopPropagation(); | |||
var q = _selText; | |||
hideActions(); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
if (q && window.showSearchDialog) { window.showSearchDialog(q); } | |||
else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); } | |||
}); | }); | ||
/ | /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */ | ||
(function () { | |||
var dismissEl = document.getElementById('gra-fab-dismiss'); | |||
if (!dismissEl) return; | |||
function doDismiss(e) { | |||
var | e.preventDefault(); e.stopPropagation(); | ||
hideActions(); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
if (window.getSelection) { | |||
var s = window.getSelection(); | |||
if ( ! | if (s && s.removeAllRanges) s.removeAllRanges(); | ||
} | |||
} | } | ||
} ); | dismissEl.addEventListener('touchend', doDismiss, { passive: false }); | ||
$ | dismissEl.addEventListener('click', doDismiss); | ||
}()); | |||
$( '#gra- | /* Feedback composer */ | ||
$fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); }); | |||
$('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer); | |||
$fbSubmit.on('click', submitFeedback); | |||
$ | $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); }); | ||
$ | |||
/* Note composer */ | |||
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') | $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); }); | ||
if (e.key==='Escape') | $('#gra-nt-cancel').on('click', closeNoteComposer); | ||
$ntSubmit.on('click', submitNote); | |||
$ntInput.on('keydown', function(e){ | |||
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote(); | |||
if (e.key==='Escape') closeNoteComposer(); | |||
}); | }); | ||
/ | /* Bookmark composer */ | ||
$( '#gra-bm-cancel' ).on('click', | $('#gra-bm-cancel').on('click', closeBookmarkComposer); | ||
$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') | if (e.key==='Escape') closeBookmarkComposer(); | ||
}); | }); | ||
// | /* Panel */ | ||
$( '#gra-panel-close' ).on('click', closePanel); | $('#gra-panel-close').on('click', closePanel); | ||
$backdrop.on('click', closePanel); | $backdrop.on('click touchend', function(e){ | ||
e.preventDefault(); | |||
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer(); | |||
$ | else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer(); | ||
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer(); | |||
else closePanel(); | |||
}); | |||
$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){ | |||
$ | if ($(e.target).hasClass('gra-note-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); } | ||
}); | |||
$paneNotes.on('click', '.gra-note-del', function(e){ | |||
e.stopPropagation(); | |||
var id = $(this).attr('data-del-id'); | |||
if (id) deleteNote(id); | |||
}); | }); | ||
$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) 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){ | ||
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); | ||
}); | |||
// 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); } | |||
}); | }); | ||
$(CONTENT_SEL).on('click', '.gra-note-highlight', function(){ | |||
$( CONTENT_SEL ).on('click', '.gra- | |||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
openPanel(' | openPanel('notes'); | ||
setTimeout(function(){ | setTimeout(function(){ | ||
var $card = $ | var $card = $paneNotes.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'}); | // 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 | |||
}); | }); | ||
$(document).on('keydown', function(e){ | |||
if (e.key !== 'Escape') return; | |||
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer(); | |||
else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer(); | |||
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer(); | |||
else closePanel(); | |||
}); | |||
} | |||
} | } | ||
// 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 | |||
if ( !h.quote || !h.id ) return; | // server. The old key is left alone rather than deleted; it is simply no | ||
if ( document.querySelector( '[data-gra-id="' + h.id + '"].gra- | // longer consulted. | ||
var needle = h.quote.replace(/…$/, '').trim().slice(0, 80); | function restoreNoteHighlights() { | ||
if ( !needle ) return; | _notes.forEach(function(h){ | ||
var range = findTextInContent( document.querySelector(CONTENT_SEL), needle ); | if (!h.quote || !h.id) return; | ||
if ( range ) | if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return; | ||
var needle = h.quote.replace(/…$/,'').trim().slice(0,80); | |||
if (!needle) return; | |||
var range = findTextInContent(document.querySelector(CONTENT_SEL), needle); | |||
if (!range) return; | |||
var sp = document.createElement('span'); | |||
sp.className = 'gra-note-highlight'; | |||
sp.setAttribute('data-gra-id', h.id); | |||
try { range.surroundContents(sp); } catch(e){} | |||
}); | }); | ||
} | } | ||
| Line 875: | Line 1,666: | ||
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) return; | ||
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 ) { | function findTextInContent(root, needle) { | ||
if (!root || !needle) 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 | try { | ||
var r = document.createRange(); | |||
r.setStart(startNode, startOffset); | |||
return | r.setEnd(endNode, endOffset); | ||
return r; | |||
} 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() { | ||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | |||
window.addEventListener('resize', function(){ | |||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | |||
}); | |||
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 { restoreBookmarkHighlights(); } catch(e){} | |||
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() { } | |||
}() ); | }() ); | ||