Bot:Gadget-figureSource.js: Difference between revisions

Content deleted Content added
Stage the figureSource gadget for verification (real home: MediaWiki:Gadget-figureSource.js, manual paste)
 
Prose address: section h2 + chunk title (title lives in the marker paragraph)
 
(14 intermediate revisions by the same user not shown)
Line 36:
var coral = (getComputedStyle(document.documentElement)
.getPropertyValue('--ed-accent') || '#f07662').trim() || '#f07662';
 
// ── zero-dependency stand-ins for mw.util / mw.loader ──────────────────
// Deliberate: on this wiki mediawiki.util can hang at "loading" under the
// page's heavy module soup (VE, popups, SMW), and a gadget waits FOREVER
// for a declared dependency (found live: the gadget sat at "loaded" while
// its dependency never resolved). This gadget therefore depends on
// nothing beyond the startup config, which is always present before
// gadgets execute.
var SCRIPT = mw.config.get('wgScript'); // "/w/index.php"
var ARTICLE = mw.config.get('wgArticlePath'); // "/wiki/$1"
function esc(s) {
return String(s).replace(/[&<>"']/g, function (ch) {
return '&#' + ch.charCodeAt(0) + ';';
});
}
function rawUrl(title) {
return SCRIPT + '?title=' + encodeURIComponent(title) + '&action=raw';
}
function pageUrl(title, params) {
if (!params) {
return ARTICLE.replace('$1', encodeURIComponent(title)
.replace(/%2F/g, '/').replace(/%3A/g, ':'));
}
var q = Object.keys(params).map(function (k) {
return k + '=' + encodeURIComponent(params[k]);
}).join('&');
return SCRIPT + '?title=' + encodeURIComponent(title) + '&' + q;
}
function addStyle(css) {
var el = document.createElement('style');
el.textContent = css;
document.head.appendChild(el);
}
function getScript(url) {
return new Promise(function (resolve, reject) {
var el = document.createElement('script');
el.src = url;
el.onload = resolve;
el.onerror = reject;
document.head.appendChild(el);
});
}
 
// ── shared: pdf.js + page-canvas cache ─────────────────────────────────
Line 41 ⟶ 83:
function loadPdfJs() {
if (!pdfjsReady) {
pdfjsReady = mw.loader.getScript(PDFJS).then(function () {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
return window.pdfjsLib;
Line 51 ⟶ 93:
function mirrorUrl(pageTitle) {
var file = pageTitle.replace(/\//g, '-') + '.pdf';
return mw.util.getUrlpageUrl('Special:FilePath/' + file);
}
 
Line 161 ⟶ 203:
var idx = 0, node;
while (idx < list.length && (node = walker.nextNode())) {
// .fig-src in the guard = idempotency: re-running the tagger
if (node.parentElement.closest('table, .fig-popover')) { continue; }
// (staging-copy injection during verification) must never
// nest a second wrapper inside an already-tagged figure
if (node.parentElement.closest(
'table, .fig-card, .fig-banner, .fig-src')) { continue; }
if (chunkOf(node) !== parseInt(chunk, 10)) { break; }
var at, from = 0;
Line 182 ⟶ 228:
}
if (idx < list.length) {
mw.logconsole.warn('[figureSource] chunk ' + chunk + ': ' +
(list.length - idx) + ' prose figure(s) not found');
}
Line 192 ⟶ 238:
var tag = function (el, entry) {
el.classList.add('fig-src');
// tabindex is the LOAD-BEARING iOS detail (the footnote cue
// template ships it too): a focusable element passes Safari's
// "clickable" heuristic, so taps reliably synthesize click
// events for document-delegated listeners. Without it, taps on
// a bare td/span inside a scroll wrapper produce NOTHING.
el.setAttribute('tabindex', '0');
byEl.set(el, entry);
};
Line 198 ⟶ 250:
var tables = Array.prototype.filter.call(
document.querySelectorAll('#mw-content-text table.wikitable'),
function (t) { return !t.closest('.fig-popovercard, .fig-banner'); });
var grids = tables.map(expandTable);
var located = sidecar.figures.concat(sidecar.unmatched || []);
Line 208 ⟶ 260:
if (!el) { skipped++; return; }
if (cellText(el).indexOf(entry.text) === -1) {
mw.logconsole.warn('[figureSource] t' + entry.loc.table + ' r' +
entry.loc.r + 'c' + entry.loc.c +
': DOM says "' + cellText(el) + '", sidecar "' +
Line 223 ⟶ 275:
}), tag);
 
mwconsole.loginfo('[figureSource] tagged ' + byEl.size + ' figure(s), ' +
skipped + ' skipped');
return byEl;
}
 
// ── the popover ────────────────────────────────────────────────────────
function popoverEl() {
var el = document.querySelector('.fig-popover');
if (el) { return el; }
el = document.createElement('div');
el.className = 'fig-popover';
document.body.appendChild(el);
document.addEventListener('click', function (ev) {
if (!el.contains(ev.target) &&
!ev.target.closest('.fig-src')) { el.remove(); }
});
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') { el.remove(); }
});
return el;
}
 
function tagsHtml(el, entry) {
// Address order, outer -> inner (user design): caption (which
// table, incl. its [t. N] index) -> column header -> row label —
// the same drill-down the sidecar's own locator uses.
var parts = [];
if (entry.loc.kind === 'cell') {
var table = el.closest('table');
var grid = expandTable(table);
var labelcap = grid[entrytable.loc.r] && grid[entry.loc.r][0]querySelector('caption');
if (label && label !== elcap) { parts.push(cellText(labelcap)); }
var headers = [];
for (var h = 0; h < grid.length; h++) {
Line 263 ⟶ 301:
}
if (headers.length) { parts.push(headers.join(' · ')); }
var caplabel = tablegrid[entry.querySelector('caption')loc.r] && grid[entry.loc.r][0];
if (caplabel && label !== el) { parts.push(cellText(caplabel)); }
} else {
// Address for a prose figure, outer -> inner: the enclosing
parts.push('chunk ' + entry.loc.chunk);
// section heading, then the CHUNK TITLE — which lives INSIDE the
// marker's own <p> ("[c. 3; p. 1] Gross written premiums and
// underlying earnings"), so it is the paragraph text minus the
// .ed-chunk span. "chunk 3" appears nowhere: readers get words.
var anchor = document.querySelector(
'span.ed-chunk[id$="-c' + entry.loc.chunk + '"]');
var blk = anchor && anchor.closest('p');
if (blk) {
var up = blk.previousElementSibling;
while (up && !/^H[1-4]$/.test(up.tagName)) {
up = up.previousElementSibling;
}
if (up) {
parts.push(up.textContent.replace(/\s+/g, ' ').trim());
}
var clone = blk.cloneNode(true);
clone.querySelectorAll('.ed-chunk').forEach(function (s) {
s.remove();
});
var title = clone.textContent.replace(/\s+/g, ' ').trim();
if (title) { parts.push(title); }
}
}
// one line, middle-dot separated ("Solvency II ratio (%) · FY24 · …")
return parts.map(function (p) {
return '<div class="fig-tag">' + mwparts.html.escape(p) + '</div>';length
} ? '<div class="fig-tags">' + parts.map(esc).join(' · '); + '</div>'
: '';
}
 
// ── source peek — the FOOTNOTE pattern (Common.js .ed-fn machinery) ────
function showPopover(el, entry, pdf) {
// One body-appended surface, never an in-flow element: a fixed CARD on
var pop = popoverEl();
// hover (desktop), a full-width top BANNER on tap (touch). Body-appended
var address = entry.page
// means root stacking context — no sticky column or table scroll wrapper
// can cover or clip it, the bug class the old in-flow popover had.
// Desktop: hover = peek (card is pointer-events:none, like .ed-fn-popover,
// so it can hold no link), click = open the viewer. Touch: tap = banner
// (interactive, carries the open link), next tap anywhere dismisses.
 
function addressOf(entry) {
return entry.page
? 'p. ' + entry.page
: (entry.reason === 'ambiguous'
? 'no source location (ambiguous ×' + entry.candidates + ')'
: 'no source location (' + (entry.reason || 'unmatched') + ')');
}
var openHref = mw.util.getUrl(VIEWER_PAGE, {
 
function peekHtml(el, entry, withLink) {
var openHref = pageUrl(VIEWER_PAGE, {
doc: mw.config.get('wgPageName'), fig: entry.id || '' });
pop.innerHTMLreturn '<div class="fig-crop">' +
'<div class="fig-crop">' +
(entry.rect ? '<canvas></canvas>' : '') + '</div>' +
tagsHtml(el, entry) +
'<div class="fig-footaddr"><span>' + mw.html.escapeesc(addressaddressOf(entry)) +
'</span>' + (withLink && entry.id ? '<a href="' + openHref +
'" target="_blank">open ↗</a>' : '') + '</div>';
}
 
function renderCrop(container, entry, pdf) {
var r = el.getBoundingClientRect();
pop.style.left = Math.min(r.left + window.scrollX,
window.scrollX + document.documentElement.clientWidth - 430) + 'px';
pop.style.top = (r.bottom + window.scrollY + 6) + 'px';
 
if (!entry.rect) { return; }
pdf.page(entry.page).then(function (pg) {
Line 304 ⟶ 372:
var h = Math.min(pg.canvas.height - y0,
(entry.rect[3] - entry.rect[1] + 2 * CROP_MARGIN_Y) * s);
var cv = popcontainer.querySelector('canvas');
if (!cv) { return; } // peek already dismissed
cv.width = w; cv.height = h;
var ctx = cv.getContext('2d');
Line 316 ⟶ 384:
(entry.rect[3] - entry.rect[1]) * s + 4);
}, function () {
popvar crop = container.querySelector('.fig-crop').textContent =;
if (crop) { crop.textContent = 'mirror PDF not available'; }
});
}
 
function attachPeek(byEl, pdf) {
var box = null, activeCue = null;
function close() {
if (box) { box.remove(); box = null; }
activeCue = null;
document.removeEventListener('click', onAway, true);
}
function onAway(e) {
// clicks INSIDE the surface never dismiss it — the open link
// must be clickable on both surfaces
if (box && box.contains(e.target)) { return; }
if (!cueOf(e)) { close(); }
}
function cueOf(e) {
var t = e.target;
if (!t || !t.closest) { return null; }
if (t.closest('.ed-fn')) { return null; } // footnote cues win
var el = t.closest('.fig-src');
return el && byEl.has(el) ? el : null;
}
var touch = window.matchMedia &&
window.matchMedia('(hover: none)').matches;
 
if (touch) {
// tap -> top banner (interactive: it carries the open link);
// the next tap anywhere dismisses — .ed-fn banner behavior
document.addEventListener('click', function (e) {
var cue = cueOf(e);
if (!cue) { return; }
e.preventDefault();
e.stopPropagation();
close();
var entry = byEl.get(cue);
box = document.createElement('div');
box.className = 'fig-banner';
box.innerHTML = peekHtml(cue, entry, true);
document.body.appendChild(box);
renderCrop(box, entry, pdf);
setTimeout(function () {
document.addEventListener('click', onAway, true);
}, 0);
}, false);
} else {
// Desktop: CLICK -> anchored card below the cell — the original
// UI, which the user preferred over a hover peek (and which the
// touch banner mirrors). The card carries the open link, so
// unlike the footnote hover popover it is interactive.
document.addEventListener('click', function (e) {
if (box && box.contains(e.target)) { return; }
var cue = cueOf(e);
if (!cue) { return; }
e.preventDefault();
e.stopPropagation();
close();
activeCue = cue;
var entry = byEl.get(cue);
box = document.createElement('div');
box.className = 'fig-card';
box.innerHTML = peekHtml(cue, entry, true);
document.body.appendChild(box);
// page coordinates (absolute), so the card scrolls WITH the
// page like the original; left clamped for narrow viewports
var r = cue.getBoundingClientRect();
box.style.left = Math.max(8, Math.min(r.left + window.scrollX,
window.scrollX + document.documentElement.clientWidth -
box.offsetWidth - 8)) + 'px';
box.style.top = (r.bottom + window.scrollY + 6) + 'px';
renderCrop(box, entry, pdf);
setTimeout(function () {
document.addEventListener('click', onAway, true);
}, 0);
}, false);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); }
});
}
}
 
Line 332 ⟶ 478:
// NB: this wiki serves EMPTY 200s for missing action=raw pages —
// existence must be tested on the body, never on r.ok.
fetch(mw.util.getUrlrawUrl('Data:' + doc + '.figures.json',))
{ action: 'raw' }))
.then(function (r) { return r.text(); })
.then(function (t) {
Line 346 ⟶ 491:
var pdf = new PdfDoc(mirrorUrl(doc));
var page = fig ? fig.page : parseInt(q.get('page') || '1', 10);
// The document name IS the back link (no separate "← back"),
// and the page label sits BETWEEN prev and next (user design).
root.innerHTML =
'<div class="fig-viewer-bar"><b>'a + mw.html.escape(class="fig-doc)" href="' +
'</b>pageUrl(doc) <a+ href='">' + mw.util.getUrlesc(doc.replace(/_/g, ' ')) +
'">← back</a><span class="fig-viewer-pagecontrols"></span>' +
'<button class="fig-prev">‹ prev</button>' +
'<buttonspan class="fig-nextviewer-page">next ›</button></divspan>' +
'<divbutton class="fig-stagenext"><canvas>next ›</canvas><div class="fig-box"button></divspan></div>'; +
'<div class="fig-stage"><div class="fig-page">' +
'<canvas></canvas><div class="fig-box"></div></div></div>';
var render = function () {
pdf.page(page).then(function (pg) {
Line 363 ⟶ 512:
var box = root.querySelector('.fig-box');
if (fig && fig.page === page) {
// PERCENT coordinates: the canvas is responsive
// (max-width:100%), so px offsets computed at
// render scale would drift the moment CSS scales
// the bitmap — percentages track the displayed
// size for free.
var s = pg.viewport.scale;
var W = pg.canvas.width, H = pg.canvas.height;
box.style.display = 'block';
box.style.left = ((fig.rect[0] * s - 3) / W * 100) + 'px%';
box.style.top = ((fig.rect[1] * s - 3) / H * 100) + 'px%';
box.style.width = (((fig.rect[2] - fig.rect[0]) * s + 6) / W * 100) + 'px%';
box.style.height = (((fig.rect[3] - fig.rect[1]) * s + 6) / H * 100) + 'px%';
box.scrollIntoView({ block: 'center' });
} else {
Line 391 ⟶ 546:
function runArchive() {
var title = mw.config.get('wgPageName').replace(/_/g, ' ');
fetch(mw.util.getUrlrawUrl('Data:' + title + '.figures.json',))
{ action: 'raw' }))
.then(function (r) { return r.text(); })
.then(function (t) { // empty 200 = no sidecar here
Line 399 ⟶ 553:
var byEl = buildArchiveMode(sidecar);
if (!byEl.size) { return; }
var pdf =attachPeek(byEl, new PdfDoc(mirrorUrl(title)));
document.addEventListener('click', function (ev) {
var el = ev.target.closest('.fig-src');
if (!el || !byEl.has(el)) { return; }
ev.preventDefault();
showPopover(el, byEl.get(el), pdf);
});
});
}
 
mw.util.addCSSaddStyle(
'.fig-src{cursor:pointer}' +
'.fig-src:hover{text-decoration:underline;' +
'text-decoration-color:' + coral + ';text-underline-offset:3px}' +
// full-bleed crop: the popover's own ink border frames the image
'.fig-popover{position:absolute;z-index:200;width:420px;' +
// (no inner padding/margins); only the text block below is inset.
'background:#fff;border:1px solid #0d0d0d;padding:10px;' +
// All popover text follows the house small size, footer in page ink.
'box-shadow:0 2px 10px rgba(0,0,0,.15)}' +
// ── peek surfaces, mirroring the footnote machinery ──
'.fig-popover .fig-crop canvas{max-width:100%;display:block;' +
// .fig-card = .ed-fn-popover's shape: fixed, body-appended (root
'border:1px solid #ddd;margin-bottom:8px}' +
// stacking context — never clipped by a table scroll wrapper),
'.fig-popover .fig-tag{font-size:13px;line-height:1.45}' +
// pointer-events:none, ink frame, house shadow. Color is pinned to
'.fig-popover .fig-foot{display:flex;justify-content:space-between;' +
// --text-dark because body-attached elements inherit the SKIN's
'margin-top:8px;font-size:12px;color:#555}' +
// #202122, not the content area's ink.
// absolute (page coords) so it scrolls with the page like the
// original popover; interactive because it carries the open link
'.fig-card{position:absolute;z-index:2147483647;' +
'max-width:min(420px,90vw);background:#fff;padding:0;' +
'border:1px solid #0d0d0d;color:var(--text-dark,#0d0d0d);' +
'box-shadow:0 4px 16px rgba(43,41,38,.18);pointer-events:auto}' +
// full-bleed crop in both surfaces (declare border/margin — an
// omitted property lets a stale stacked stylesheet rule survive)
'.fig-card canvas,.fig-banner canvas{width:100%;height:auto;' +
'display:block;border:0;margin:0}' +
'.fig-card .fig-crop{border-bottom:1px solid #ddd}' +
'.fig-card .fig-crop:empty,.fig-banner .fig-crop:empty{display:none}' +
'.fig-tags{font-size:var(--ed-small,14px);line-height:1.45}' +
'.fig-card .fig-tags{padding:8px 10px 0}' +
'.fig-addr{display:flex;justify-content:space-between;' +
'font-size:var(--ed-small,14px);color:inherit}' +
'.fig-card .fig-addr{padding:6px 10px 10px}' +
// .fig-banner = .ed-fn-banner's shape: full-width, pinned to the
// top, near-black, safe-area padded — the touch surface, and it IS
// interactive (carries the open link), unlike the hover card
'.fig-banner{position:fixed;top:0;left:0;right:0;' +
'z-index:2147483647;box-sizing:border-box;' +
'padding:calc(0.9em + env(safe-area-inset-top)) 1.2em 0.9em;' +
'background:rgba(20,18,16,0.92);color:#fff;' +
'font-size:var(--ed-small,14px);line-height:1.5}' +
'.fig-banner canvas{margin-bottom:8px}' +
'.fig-banner .fig-addr{margin-top:4px}' +
'.fig-banner a{color:#fff;text-decoration:underline}' +
// one explicit size for every bar item — name, page label, buttons —
// so nothing inherits a divergent size from the surrounding skin
// --ed-small is THE house small size (chunk markers, tables, infobox
// all use it) — the viewer chrome follows it by contract, not by a
// literal that happens to match today
'.fig-viewer-bar{display:flex;gap:14px;align-items:center;' +
'padding:8px 0;border-bottom:1px solid #0d0d0d;margin-bottom:12px};' +
'.fig-viewer-bar button{font-size:inheritvar(--ed-small,14px);backgroundborder-bottom:#fff;0}' +
'.fig-viewer-bar .fig-doc{font-weight:bold;' +
'font-size:var(--ed-small,14px)}' +
'.fig-controls{display:flex;gap:10px;align-items:center;' +
'margin-left:auto}' +
'.fig-viewer-bar button{font:inherit;' +
'font-size:var(--ed-small,14px);background:#fff;' +
'border:1px solid #0d0d0d;padding:2px 12px;cursor:pointer}' +
'.fig-stage{position:relative;overflow:auto;text-align:center}' +
// the page frame: ink border so the white page reads as an OBJECT on
'.fig-stage canvas{display:block;margin:0 auto}' +
// the white wiki background; position:relative so the coral box is
// canvas-anchored (absolute children resolve against the padding box,
// so the border adds no offset) — centering via the stage no longer
// drifts the box on wide windows
'.fig-page{position:relative;display:inline-block;' +
'border:1px solid #0d0d0d;background:#fff}' +
// responsive page: never wider than the column, so the right border
// stays visible and no horizontal scrollbar (the house dark
// scrollbar was the "thick dark line" under the page)
'.fig-page canvas{display:block;max-width:100%;height:auto}' +
'.fig-box{position:absolute;display:none;border:3px solid ' + coral +
';pointer-events:none}');
 
function boot() {
if (mw.config.get('wgPageName') === VIEWER_PAGE.replace(/ /g, '_')) {
if (mw.config.get('wgPageName') === VIEWER_PAGE.replace(/ /g, '_')) {
runViewer();
runViewer();
} else if (mw.config.get('wgNamespaceNumber') === 0) {
} else if (mw.config.get('wgNamespaceNumber') === 0) {
runArchive();
runArchive();
}
}
// A dependency-free gadget can execute before the content is parsed.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());