MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| (4 intermediate revisions by the same user not shown) | |||
| Line 588: | 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 800: | Line 799: | ||
closeNoteComposer(); | closeNoteComposer(); | ||
openPanel('notes'); | 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')) || '' | |||
}; | |||
} | |||
// 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 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 828: | Line 1,130: | ||
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); } | ||
var rec = {id:id, name:name, quote:quote, ts:nowIso()}; | 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') || '') : '', | |||
citeSource: citeSourceFor(anchorEl || document.body)}; | |||
_bookmarks.push(rec); | _bookmarks.push(rec); | ||
Store.cache(); | Store.cache(); | ||
| Line 914: | 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 919: | 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 1,213: | 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 1,375: | 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() { | function paint() { | ||
| Line 1,397: | Line 1,750: | ||
// the panel without opening it — worth having now that notes can arrive | // the panel without opening it — worth having now that notes can arrive | ||
// from another device. | // 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() { } | |||
}() ); | }() ); | ||