3528 lines
160 KiB
JavaScript
3528 lines
160 KiB
JavaScript
// Magazine reader — contents tree, article review, dual reading views,
|
||
// sentence-highlighted playback and podcast mode.
|
||
//
|
||
// The canonical object is the ARTICLE, not the page (see
|
||
// architecture/magazine-ingestion.md). Nothing is narrated until a human has
|
||
// approved the detected boundaries, so the review screen is on the critical
|
||
// path rather than an optional extra.
|
||
//
|
||
// Two views stay linked: the reflowed article (comfortable reading, sentence
|
||
// highlighting) and the original PDF page (the visual source of truth). Every
|
||
// block carries page + bbox, so either view can jump to the other.
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
const MAG = root.TTVC = root.TTVC || {};
|
||
const state = {
|
||
docs: [],
|
||
doc: null, // { id, title, issue, ingest, ... }
|
||
articles: [],
|
||
layout: null, // { page_count, pages[], blocks[] }
|
||
article: null, // the article being read or reviewed
|
||
sentences: [], // [{ text, blockId, page, bbox }]
|
||
images: [], // current article's figures, in reading order
|
||
view: 'library', // library | contents | review | read
|
||
contentsView: 'list', // list | grid — how the article list itself is shown
|
||
contentsSelectMode: false, // batch-select articles for bulk approve/reject
|
||
contentsSelected: new Set(),
|
||
playlistCurrent: null, // article id of the MP3 playlist's current track
|
||
pane: 'reflow', // reflow | pdf
|
||
playing: false,
|
||
cancel: false,
|
||
idx: 0, // current sentence
|
||
audio: null,
|
||
voices: [],
|
||
podcast: null,
|
||
queue: [], // article ids for "play all" (empty = not queued)
|
||
queueIndex: -1,
|
||
driving: false, // large-control, minimal-interaction view
|
||
karaoke: true, // word-level highlighting (approximate, see estimateWordTimings)
|
||
drawMode: false, // manually drawing a region OCR/layout missed, in review
|
||
reviewSpan: [],
|
||
reviewPage: null,
|
||
reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread
|
||
audioCache: new Map(), // voice+text -> Promise<Blob>, see magSynth
|
||
preparing: false, // "Prepare audio" is synthesising the whole article
|
||
prepCancel: false,
|
||
prepGen: 0, // bumped on article change so a stale prepare run stops touching state
|
||
};
|
||
MAG.magazine = state;
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
|
||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
|
||
function say(msg, kind) { if (typeof toast === 'function') toast(msg, kind || ''); }
|
||
|
||
async function api(path, opts) {
|
||
const r = await fetch(path, opts);
|
||
if (!r.ok) {
|
||
let detail = r.statusText;
|
||
try { detail = (await r.json()).detail || detail; } catch (_) {}
|
||
throw new Error(detail);
|
||
}
|
||
const type = r.headers.get('content-type') || '';
|
||
return type.includes('json') ? r.json() : r;
|
||
}
|
||
|
||
function fmtDuration(seconds) {
|
||
const s = Math.max(0, Math.round(seconds || 0));
|
||
if (s < 60) return s + ' sec';
|
||
const m = Math.round(s / 60);
|
||
return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min';
|
||
}
|
||
|
||
function fmtPages(pages) {
|
||
if (!pages || !pages.length) return '—';
|
||
if (pages.length === 1) return 'p. ' + pages[0];
|
||
// Non-contiguous page lists are the norm once a feature jumps, and showing
|
||
// "18–88" for pages [18, 88] would misdescribe it as a 70-page article.
|
||
const runs = [];
|
||
let start = pages[0], prev = pages[0];
|
||
for (let i = 1; i <= pages.length; i++) {
|
||
if (pages[i] === prev + 1) { prev = pages[i]; continue; }
|
||
runs.push(start === prev ? String(start) : start + '–' + prev);
|
||
start = prev = pages[i];
|
||
}
|
||
return 'pp. ' + runs.join(', ');
|
||
}
|
||
|
||
// The live OCR view is intentionally split: a portrait source page needs more
|
||
// horizontal room than a dense recognised-text pane on some screens, while on
|
||
// others the text is the thing being inspected. Keep a per-browser choice so
|
||
// the user does not have to re-adjust it for every magazine/page.
|
||
function setupMagIngestPreviewResize() {
|
||
const preview = $('mag-ingest-preview');
|
||
const grip = $('mag-ingest-preview-resize');
|
||
if (!preview || !grip || preview.dataset.resizeWired) return;
|
||
preview.dataset.resizeWired = '1';
|
||
const storageKey = 'ttsvc_mag_ingest_page_pane_pct';
|
||
const apply = (value) => {
|
||
const pct = Math.max(30, Math.min(70, Number(value) || 50));
|
||
preview.style.gridTemplateColumns = `${pct}% 8px calc(${100 - pct}% - 8px)`;
|
||
return pct;
|
||
};
|
||
try {
|
||
const saved = localStorage.getItem(storageKey);
|
||
if (saved != null) apply(saved);
|
||
} catch (_) {}
|
||
grip.addEventListener('pointerdown', (event) => {
|
||
if (window.matchMedia('(max-width: 860px)').matches) return;
|
||
event.preventDefault();
|
||
preview.classList.add('is-resizing');
|
||
const move = (e) => {
|
||
const rect = preview.getBoundingClientRect();
|
||
apply(((e.clientX - rect.left) / rect.width) * 100);
|
||
};
|
||
const stop = (e) => {
|
||
window.removeEventListener('pointermove', move);
|
||
window.removeEventListener('pointerup', stop);
|
||
preview.classList.remove('is-resizing');
|
||
const rect = preview.getBoundingClientRect();
|
||
const pct = apply(((e.clientX - rect.left) / rect.width) * 100);
|
||
try { localStorage.setItem(storageKey, String(pct)); } catch (_) {}
|
||
};
|
||
window.addEventListener('pointermove', move);
|
||
window.addEventListener('pointerup', stop, { once: true });
|
||
});
|
||
}
|
||
|
||
// ── Library ──────────────────────────────────────────────────────────────────
|
||
|
||
async function magLoadLibrary() {
|
||
const data = await api('/api/reader/docs');
|
||
state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
|
||
renderLibrary();
|
||
}
|
||
|
||
function renderLibrary() {
|
||
const host = $('mag-library-list');
|
||
if (!host) return;
|
||
if (!state.docs.length) {
|
||
host.innerHTML =
|
||
'<p class="muted" style="padding:18px 0">No magazines yet. Add one to get started — ' +
|
||
'upload a PDF and the articles are detected for you.</p>';
|
||
return;
|
||
}
|
||
host.innerHTML = state.docs.map((d) => {
|
||
const ing = d.ingest || {};
|
||
const badge = ing.status === 'done'
|
||
? '<span class="mag-pill ok">' + (ing.articles || 0) + ' articles</span>'
|
||
: ing.status === 'running' ? '<span class="mag-pill warn">ingesting…</span>'
|
||
: ing.status === 'failed' ? '<span class="mag-pill bad">failed</span>'
|
||
: '<span class="mag-pill">not ingested</span>';
|
||
const thumbnail = '<img class="mag-card-thumb" src="/api/magazine/' + encodeURIComponent(d.id) +
|
||
'/pages/1" alt="First page of ' + esc(d.title || 'magazine') +
|
||
'" loading="lazy" onerror="this.hidden=true">';
|
||
return '<div class="mag-card-wrap"><button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
|
||
thumbnail + '<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
|
||
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>' +
|
||
'<div class="mag-card-actions"><button type="button" class="mag-card-delete" onclick="magDelete(\'' + d.id + '\')" ' +
|
||
'title="Delete this magazine" aria-label="Delete ' + esc(d.title || 'magazine') + '"><span class="mdi mdi-delete-outline"></span> Delete</button></div></div>';
|
||
}).join('');
|
||
}
|
||
|
||
async function magDelete(docId) {
|
||
const doc = state.docs.find((item) => item.id === docId);
|
||
const title = doc?.title || 'this magazine';
|
||
if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return;
|
||
try {
|
||
await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' });
|
||
if (state.doc?.id === docId) {
|
||
magForgetLastDoc();
|
||
state.doc = null;
|
||
state.article = null;
|
||
}
|
||
state.docs = state.docs.filter((item) => item.id !== docId);
|
||
renderLibrary();
|
||
say(`Deleted “${title}”`, 'ok');
|
||
} catch (e) {
|
||
say('Could not delete magazine: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
|
||
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
|
||
|
||
function magFormatFilenameAsTitle(filename) {
|
||
if (!filename) return '';
|
||
const base = String(filename).replace(/\.[^.]+$/, '');
|
||
return base
|
||
.replace(/[_-]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function magShowDroppedFile(file) {
|
||
const noteEl = $('mag-dropzone-note');
|
||
const titleEl = $('mag-dropzone-title');
|
||
const iconEl = $('mag-dropzone-icon');
|
||
const titleInput = $('mag-new-title');
|
||
const dropzone = $('mag-dropzone');
|
||
|
||
if (file) {
|
||
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
||
if (dropzone) dropzone.classList.add('is-selected');
|
||
if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`;
|
||
if (titleEl) titleEl.textContent = 'PDF file selected — uploading...';
|
||
if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon';
|
||
|
||
// Auto-fill title from filename if title input is empty
|
||
if (titleInput && !titleInput.value.trim()) {
|
||
titleInput.value = magFormatFilenameAsTitle(file.name);
|
||
}
|
||
} else {
|
||
if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading');
|
||
if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE;
|
||
if (titleEl) titleEl.textContent = 'Drop a PDF document';
|
||
if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon';
|
||
}
|
||
}
|
||
|
||
function installGlobalMagDropGuard() {
|
||
if (window._magDropGuardInstalled) return;
|
||
window._magDropGuardInstalled = true;
|
||
const guard = (e) => {
|
||
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
|
||
e.preventDefault();
|
||
if (e.type === 'dragover' && e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
};
|
||
window.addEventListener('dragenter', guard, true);
|
||
window.addEventListener('dragover', guard, true);
|
||
window.addEventListener('drop', guard, true);
|
||
}
|
||
installGlobalMagDropGuard();
|
||
|
||
function setupMagDropzone() {
|
||
installGlobalMagDropGuard();
|
||
const zone = $('mag-dropzone');
|
||
const input = $('mag-new-file');
|
||
if (!zone || !input) return;
|
||
|
||
if (zone.dataset.magDropzoneWired) return;
|
||
zone.dataset.magDropzoneWired = '1';
|
||
|
||
// Clicking anywhere in the dropzone triggers the hidden file picker
|
||
zone.addEventListener('click', (e) => {
|
||
if (!e.target.closest('input')) {
|
||
input.click();
|
||
}
|
||
});
|
||
|
||
let dragCounter = 0;
|
||
|
||
zone.addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter++;
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
zone.classList.add('dragover');
|
||
});
|
||
|
||
zone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
zone.classList.add('dragover');
|
||
});
|
||
|
||
zone.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter = Math.max(0, dragCounter - 1);
|
||
if (dragCounter === 0) {
|
||
zone.classList.remove('dragover');
|
||
}
|
||
});
|
||
|
||
zone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter = 0;
|
||
zone.classList.remove('dragover');
|
||
|
||
let file = e.dataTransfer?.files?.[0];
|
||
if (!file && e.dataTransfer?.items?.[0]) {
|
||
file = e.dataTransfer.items[0].getAsFile();
|
||
}
|
||
if (!file) return;
|
||
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
|
||
return say('Only PDF files are supported', 'err');
|
||
}
|
||
magShowDroppedFile(file);
|
||
magCreate(file);
|
||
});
|
||
|
||
input.addEventListener('change', () => {
|
||
const file = input.files?.[0];
|
||
if (file) {
|
||
magShowDroppedFile(file);
|
||
magCreate(file);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function magCreate(droppedFile = null) {
|
||
const file = droppedFile || $('mag-new-file')?.files?.[0];
|
||
if (!file) return say('Choose or drop a PDF file first', 'err');
|
||
|
||
let title = ($('mag-new-title')?.value || '').trim();
|
||
if (!title) {
|
||
title = magFormatFilenameAsTitle(file.name) || 'Untitled Magazine';
|
||
if ($('mag-new-title')) $('mag-new-title').value = title;
|
||
}
|
||
const issue = ($('mag-new-issue')?.value || '').trim();
|
||
|
||
const addBtn = $('mag-add-btn');
|
||
const browseBtn = $('mag-browse-btn');
|
||
const dropzone = $('mag-dropzone');
|
||
const titleInp = $('mag-new-title');
|
||
const issueInp = $('mag-new-issue');
|
||
const progress = $('mag-dropzone-progress');
|
||
const pickBtn = $('mag-dropzone-pick-btn');
|
||
const titleEl = $('mag-dropzone-title');
|
||
const noteEl = $('mag-dropzone-note');
|
||
const iconEl = $('mag-dropzone-icon');
|
||
const origBtnText = addBtn ? addBtn.innerHTML : '';
|
||
|
||
const setUploadingState = (uploading) => {
|
||
const sizeMb = file ? (file.size / (1024 * 1024)).toFixed(1) : 0;
|
||
if (uploading) {
|
||
if (dropzone) {
|
||
dropzone.classList.add('is-uploading');
|
||
dropzone.style.pointerEvents = 'none';
|
||
}
|
||
if (progress) progress.hidden = false;
|
||
if (pickBtn) pickBtn.hidden = true;
|
||
if (browseBtn) browseBtn.hidden = true;
|
||
if (iconEl) iconEl.className = 'mdi mdi-loading mdi-spin reader-dropzone-icon';
|
||
if (titleEl) titleEl.textContent = 'Uploading & Ingesting PDF...';
|
||
if (noteEl) noteEl.textContent = `Processing "${file.name}" (${sizeMb} MB). Transferring source file and extracting layout...`;
|
||
if (addBtn) {
|
||
addBtn.disabled = true;
|
||
addBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Uploading PDF...';
|
||
}
|
||
if (titleInp) titleInp.disabled = true;
|
||
if (issueInp) issueInp.disabled = true;
|
||
} else {
|
||
if (dropzone) {
|
||
dropzone.classList.remove('is-uploading');
|
||
dropzone.style.pointerEvents = 'auto';
|
||
}
|
||
if (progress) progress.hidden = true;
|
||
if (pickBtn) pickBtn.hidden = false;
|
||
if (browseBtn) browseBtn.hidden = false;
|
||
if (addBtn) {
|
||
addBtn.disabled = false;
|
||
addBtn.innerHTML = origBtnText;
|
||
}
|
||
if (titleInp) titleInp.disabled = false;
|
||
if (issueInp) issueInp.disabled = false;
|
||
}
|
||
};
|
||
|
||
try {
|
||
setUploadingState(true);
|
||
say(`Uploading ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`, 'ok');
|
||
const { id } = await api('/api/magazine/docs', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, issue }),
|
||
});
|
||
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file });
|
||
say('Magazine uploaded — starting ingestion', 'ok');
|
||
|
||
if ($('mag-new-title')) $('mag-new-title').value = '';
|
||
if ($('mag-new-issue')) $('mag-new-issue').value = '';
|
||
if ($('mag-new-file')) $('mag-new-file').value = '';
|
||
magShowDroppedFile(null);
|
||
setUploadingState(false);
|
||
|
||
await magOpen(id);
|
||
await magIngest();
|
||
} catch (e) {
|
||
setUploadingState(false);
|
||
say('Could not add magazine: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// ── Ingestion ────────────────────────────────────────────────────────────────
|
||
|
||
// Remembers which magazine was open so a full page reload or the browser's
|
||
// own Back button (which, unlike in-app navigation, tears down all JS state)
|
||
// lands back on it instead of the empty library — confirmed live as the
|
||
// cause of a user re-uploading the same PDF, creating a duplicate under a
|
||
// completely different kind ("book", via Studio/Reader's own import) because
|
||
// the magazine they had been working on appeared to have vanished.
|
||
const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
|
||
const MAG_CONTENTS_VIEW_KEY = 'ttsvc_mag_contents_view';
|
||
|
||
async function magOpen(docId) {
|
||
try {
|
||
state.doc = await api('/api/reader/docs/' + docId);
|
||
state.doc.id = docId;
|
||
await magRefreshArticles();
|
||
magShow('contents');
|
||
try { localStorage.setItem(MAG_LAST_DOC_KEY, docId); } catch (_) {}
|
||
if ((state.doc.ingest || {}).status === 'running') pollIngest();
|
||
} catch (e) {
|
||
say('Could not open magazine: ' + e.message, 'err');
|
||
try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
function magForgetLastDoc() {
|
||
try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
|
||
}
|
||
|
||
async function magIngest() {
|
||
if (!state.doc) return;
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/ingest', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
pollIngest();
|
||
} catch (e) {
|
||
say('Ingestion could not start: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// "OCR again" for the whole issue: forces every page through the vision
|
||
// engine regardless of its current quality, then re-detects articles --
|
||
// which is why this asks for confirmation. A single page's "OCR again" in
|
||
// review does not touch article grouping; re-reading the whole document
|
||
// does, since detection runs again over the freshly re-read text.
|
||
async function magReocrMagazine() {
|
||
if (!state.doc) return;
|
||
const hasReviewed = state.articles.some((a) => a.status !== 'detected');
|
||
if (hasReviewed && !confirm(
|
||
'Re-reading the whole magazine runs article detection again. ' +
|
||
'Any articles you already approved or rejected will need reviewing again. Continue?'
|
||
)) return;
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/ingest', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ force_vision_ocr: true }),
|
||
});
|
||
pollIngest();
|
||
} catch (e) {
|
||
say('Could not start OCR: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// "Analysing page 28 of 204" -> a real percentage, not just spinner text.
|
||
function magParsePageProgress(detail) {
|
||
const m = /page (\d+) of (\d+)/i.exec(detail || '');
|
||
if (!m) return null;
|
||
const page = parseInt(m[1], 10), total = parseInt(m[2], 10);
|
||
if (!total) return null;
|
||
return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) };
|
||
}
|
||
|
||
// ── Live boxes on the page being read ─────────────────────────────────────
|
||
// The regions of the page currently being read (and, in the labelling pass, being re-typed) are
|
||
// drawn over the page image as they arrive, coloured by type and numbered in reading order.
|
||
|
||
const MAG_PREVIEW_TYPES = {
|
||
title: 'Headline', heading: 'Subheading', deck: 'Standfirst', byline: 'Author', body: 'Paragraph',
|
||
caption: 'Caption', pullquote: 'Pull quote', sidebar: 'Sidenote', ad: 'Advertisement',
|
||
furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture',
|
||
figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code',
|
||
};
|
||
let _magPreviewSeen = { page: null, keys: new Set() };
|
||
|
||
// Fit the box layer to the part of the frame the page image actually covers ("contain" scaling
|
||
// leaves bars beside or below it -- the page is top-aligned), so a box drawn at 50% sits at 50% of
|
||
// the page, not of the frame.
|
||
function magFitPreviewLayer() {
|
||
const img = $('mag-ingest-preview-img'), layer = $('mag-ingest-preview-boxes');
|
||
if (!img || !layer || !img.naturalWidth || !img.clientWidth) return;
|
||
const scale = Math.min(img.clientWidth / img.naturalWidth, img.clientHeight / img.naturalHeight);
|
||
const w = img.naturalWidth * scale, h = img.naturalHeight * scale;
|
||
layer.style.left = (img.offsetLeft + (img.clientWidth - w) / 2) + 'px';
|
||
layer.style.top = img.offsetTop + 'px'; // the page is drawn at the top of its frame (object-position: top)
|
||
layer.style.width = w + 'px';
|
||
layer.style.height = h + 'px';
|
||
}
|
||
|
||
function magRenderPreviewBoxes(page, boxes, stage) {
|
||
const layer = $('mag-ingest-preview-boxes'), img = $('mag-ingest-preview-img');
|
||
if (!layer) return;
|
||
const stageEl = $('mag-ingest-preview-stage');
|
||
if (stageEl) stageEl.textContent = page ? '· ' + page + (stage === 'labelling' ? ' — labelling regions' : ' — reading') : '';
|
||
// A page-sized embedded picture is the page scan itself, not a picture on the page (the server
|
||
// drops it too; this also covers a preview written by an older server).
|
||
boxes = (boxes || []).filter((b) => !(b.type === 'image' && b.w * b.h >= 8500));
|
||
if (_magPreviewSeen.page !== page) _magPreviewSeen = { page, keys: new Set() };
|
||
const key = (b) => [b.x, b.y, b.w, b.h].join(',');
|
||
layer.innerHTML = boxes.map((b) => {
|
||
const isNew = !_magPreviewSeen.keys.has(key(b));
|
||
const type = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body';
|
||
return '<div class="mag-preview-box mpt-' + type + (isNew ? ' is-new' : '') + '" style="left:' + b.x + '%;top:' + b.y +
|
||
'%;width:' + b.w + '%;height:' + b.h + '%" title="' + esc(MAG_PREVIEW_TYPES[type]) + '">' +
|
||
(b.n ? '<span class="mag-preview-box-num">' + b.n + '</span>' : '') + '</div>';
|
||
}).join('');
|
||
boxes.forEach((b) => _magPreviewSeen.keys.add(key(b)));
|
||
const legend = $('mag-ingest-preview-legend');
|
||
if (legend) {
|
||
// One entry per label: a picture region and an embedded picture are both "Picture".
|
||
const byLabel = new Map();
|
||
boxes.forEach((b) => { const t = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body'; if (!byLabel.has(MAG_PREVIEW_TYPES[t])) byLabel.set(MAG_PREVIEW_TYPES[t], t); });
|
||
legend.innerHTML = [...byLabel].map(([label, t]) => '<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + t + '"></span>' +
|
||
esc(label) + '</span>').join('') +
|
||
// The first pass only reads the text; pictures, charts and tables are recognised in the second.
|
||
(stage === 'reading' ? '<span class="mag-preview-legend-hint">Pictures, charts and tables are marked in the second pass</span>' : '') +
|
||
(boxes.length ? '<span class="mag-preview-legend-count">' + boxes.length + ' regions</span>' : '');
|
||
}
|
||
if (img && !img.dataset.fitBound) {
|
||
img.dataset.fitBound = '1';
|
||
img.addEventListener('load', magFitPreviewLayer);
|
||
if (typeof ResizeObserver === 'function') new ResizeObserver(magFitPreviewLayer).observe(img);
|
||
}
|
||
magFitPreviewLayer();
|
||
}
|
||
|
||
async function pollIngest() {
|
||
const bar = $('mag-ingest-status');
|
||
const preview = $('mag-ingest-preview');
|
||
if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
|
||
let lastPreviewPage = null;
|
||
for (;;) {
|
||
let data;
|
||
try {
|
||
data = await api('/api/magazine/' + state.doc.id + '/ingest/status');
|
||
} catch (_) { break; }
|
||
const ing = data.ingest || {};
|
||
const detail = (data.job || {}).detail || '';
|
||
if (bar) {
|
||
if (ing.status === 'running') {
|
||
bar.className = 'mag-status is-running';
|
||
const p = magParsePageProgress(detail);
|
||
bar.innerHTML =
|
||
'<div class="mag-ingest-row">' +
|
||
'<span class="mdi mdi-progress-clock mag-ingest-icon"></span>' +
|
||
'<div class="mag-ingest-text">' +
|
||
'<strong>' + esc(detail || 'Analysing…') + '</strong>' +
|
||
(p ? '<div class="mag-ingest-track"><div class="mag-ingest-fill" style="width:' + p.pct + '%"></div></div>' : '') +
|
||
'</div>' +
|
||
(p ? '<span class="mag-ingest-pct">' + p.pct + '%</span>' : '') +
|
||
'</div>';
|
||
} else if (ing.status === 'failed') {
|
||
bar.className = 'mag-status is-failed';
|
||
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-alert mag-ingest-icon"></span>' +
|
||
'<span>Ingestion failed: ' + esc(ing.error || '') + '</span></div>';
|
||
} else {
|
||
bar.className = 'mag-status is-done';
|
||
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-check-circle mag-ingest-icon"></span>' +
|
||
'<span>' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected</span></div>';
|
||
}
|
||
}
|
||
// Live "source page / recognised text" preview, side by side -- same
|
||
// idea as ocr-baidu-unlimited-ocr's own demo UI. Only swaps the image
|
||
// when the page actually changed, so it doesn't flicker on every poll.
|
||
if (preview) {
|
||
if (ing.status === 'running' && data.preview && data.preview.page) {
|
||
preview.hidden = false;
|
||
if (data.preview.page !== lastPreviewPage) {
|
||
lastPreviewPage = data.preview.page;
|
||
const img = $('mag-ingest-preview-img');
|
||
if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page;
|
||
}
|
||
magRenderPreviewBoxes(data.preview.page, data.preview.boxes || [], data.preview.stage);
|
||
const textEl = $('mag-ingest-preview-text');
|
||
// Server-built HTML (see _render_ingest_preview_html in
|
||
// routes/magazine.py) -- every text node in it is already escaped,
|
||
// so this is the one place that inner-HTMLs a live server value.
|
||
if (textEl) textEl.innerHTML = data.preview.text || '';
|
||
} else if (ing.status !== 'running') {
|
||
preview.hidden = true;
|
||
}
|
||
}
|
||
if (ing.status !== 'running') {
|
||
if (ing.status === 'done') await magRefreshArticles();
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
}
|
||
}
|
||
|
||
async function magRefreshArticles() {
|
||
if (!state.doc) return;
|
||
try {
|
||
const data = await api('/api/magazine/' + state.doc.id + '/articles');
|
||
state.articles = data.articles || [];
|
||
state.doc.progress = data.progress || {};
|
||
state.doc.podcast = data.podcast || { hosts: [] };
|
||
state.doc.default_voice = data.default_voice || '';
|
||
} catch (_) {
|
||
state.articles = [];
|
||
}
|
||
renderContents();
|
||
}
|
||
|
||
// ── Contents tree ────────────────────────────────────────────────────────────
|
||
|
||
function confidenceLabel(c) {
|
||
// 0.55 is the backend's explicit heuristic marker: no reliable contents
|
||
// entry was available, so an article was started from a page headline. It
|
||
// is not a calibrated probability and a column of identical “55%” badges
|
||
// falsely suggests a measured OCR score.
|
||
if (Math.abs(Number(c) - 0.55) < 0.001) {
|
||
return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' };
|
||
}
|
||
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' };
|
||
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' };
|
||
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' };
|
||
}
|
||
|
||
function renderContents() {
|
||
const host = $('mag-contents');
|
||
if (!host) return;
|
||
const title = $('mag-doc-title');
|
||
if (title && state.doc) {
|
||
title.textContent = (state.doc.title || 'Magazine') +
|
||
(state.doc.issue ? ' — ' + state.doc.issue : '');
|
||
}
|
||
if (!state.articles.length) {
|
||
host.innerHTML = '<p class="muted">No articles yet. Run ingestion to detect them.</p>';
|
||
return;
|
||
}
|
||
const bySection = new Map();
|
||
state.articles.forEach((a) => {
|
||
const key = a.section || 'Articles';
|
||
if (!bySection.has(key)) bySection.set(key, []);
|
||
bySection.get(key).push(a);
|
||
});
|
||
|
||
const grid = state.contentsView === 'grid';
|
||
const toggleBtn = $('mag-contents-view-toggle');
|
||
if (toggleBtn) {
|
||
toggleBtn.innerHTML = grid
|
||
? '<span class="mdi mdi-view-list-outline"></span> List'
|
||
: '<span class="mdi mdi-view-grid-outline"></span> Thumbnails';
|
||
toggleBtn.setAttribute('aria-pressed', String(grid));
|
||
}
|
||
|
||
const actionsHtml = (a, done, progress) =>
|
||
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
|
||
(done
|
||
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
|
||
(progress ? 'Continue' : 'Listen') + '</button>'
|
||
: '') +
|
||
(a.has_audio
|
||
? '<a class="btn-sm" href="/api/magazine/' + esc(state.doc.id) + '/articles/' + a.id + '/audio" download ' +
|
||
'title="Download the exported MP3 (with title, author, cover and lyrics tags)"><span class="mdi mdi-download"></span> MP3</a>'
|
||
: '');
|
||
const selectHtml = (a) => !state.contentsSelectMode ? '' :
|
||
'<label class="mag-select-check" onclick="event.stopPropagation()">' +
|
||
'<input type="checkbox" ' + (state.contentsSelected.has(a.id) ? 'checked' : '') +
|
||
' onchange="magContentsToggleArticle(\'' + a.id + '\')" aria-label="Select ' + esc(a.title || 'this article') + '"></label>';
|
||
// Detection routinely mangles a title through OCR ("Sbimmb das? c'b
|
||
// prüFb") -- fixing it should not require opening Review. contenteditable
|
||
// rather than swapping in an <input> keeps the card's own font/wrapping
|
||
// instead of a generic text box.
|
||
const titleHtml = (a, cls) =>
|
||
'<span class="' + cls + '" contenteditable="true" spellcheck="false" ' +
|
||
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
|
||
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
|
||
'onclick="event.stopPropagation()" title="Click to edit the title">' +
|
||
esc(a.title || 'Untitled') + '</span>';
|
||
|
||
let html = '';
|
||
bySection.forEach((arts, section) => {
|
||
html += '<div class="mag-section"><h4>' + esc(section) + '</h4>' + (grid ? '<div class="mag-grid">' : '<ul class="mag-toc">');
|
||
arts.forEach((a) => {
|
||
const conf = confidenceLabel(a.confidence);
|
||
const done = a.status === 'approved';
|
||
const progress = (state.doc.progress || {})[a.id];
|
||
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
|
||
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>';
|
||
if (grid) {
|
||
const firstPage = (a.pages || [])[0];
|
||
html += '<div class="mag-card-wrap"><div class="mag-card' + (done ? ' is-approved' : '') + '">' +
|
||
selectHtml(a) +
|
||
(firstPage
|
||
? '<img class="mag-card-thumb" loading="lazy" alt="Page ' + firstPage + '" ' +
|
||
'src="/api/magazine/' + esc(state.doc.id) + '/pages/' + firstPage + '">'
|
||
: '<div class="mag-card-thumb"></div>') +
|
||
titleHtml(a, 'mag-card-title') +
|
||
(a.author ? '<span class="mag-card-sub">by ' + esc(a.author) + '</span>' : '') +
|
||
'<span class="mag-card-sub">' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||
'<div class="mag-toc-meta"><span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||
esc(conf.badge) + '</span>' + statusBadge + '</div>' +
|
||
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div>' +
|
||
'</div></div>';
|
||
} else {
|
||
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
|
||
selectHtml(a) +
|
||
'<div class="mag-toc-main">' +
|
||
titleHtml(a, 'mag-toc-title') +
|
||
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
|
||
'</div>' +
|
||
'<div class="mag-toc-meta">' +
|
||
'<span>' + fmtPages(a.pages) + '</span>' +
|
||
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||
esc(conf.badge) + '</span>' +
|
||
statusBadge +
|
||
'</div>' +
|
||
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
|
||
}
|
||
});
|
||
html += grid ? '</div></div>' : '</ul></div>';
|
||
});
|
||
host.innerHTML = html;
|
||
renderContentsBulkBar();
|
||
magUpdateAudioZipLink();
|
||
}
|
||
|
||
function magToggleContentsView() {
|
||
state.contentsView = state.contentsView === 'grid' ? 'list' : 'grid';
|
||
try { localStorage.setItem(MAG_CONTENTS_VIEW_KEY, state.contentsView); } catch (_) {}
|
||
renderContents();
|
||
}
|
||
|
||
// ── Batch approve/reject from the article list ──────────────────────────────
|
||
// Reviewing every single article one at a time does not scale to a 200-page
|
||
// issue with 50+ detected pieces. This accepts each selected article exactly
|
||
// as auto-detected (no block-list edit, unlike a normal Approve from inside
|
||
// review) -- a deliberate trade-off: bulk action is for the pieces that
|
||
// already look right, not a replacement for reviewing a messy one.
|
||
function magContentsToggleSelect() {
|
||
state.contentsSelectMode = !state.contentsSelectMode;
|
||
if (!state.contentsSelectMode) state.contentsSelected.clear();
|
||
const btn = $('mag-contents-select-toggle');
|
||
if (btn) btn.setAttribute('aria-pressed', String(state.contentsSelectMode));
|
||
renderContents();
|
||
}
|
||
|
||
async function magContentsCommitTitle(el, id) {
|
||
const a = state.articles.find((x) => x.id === id);
|
||
if (!a) return;
|
||
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; }
|
||
const previous = a.title;
|
||
a.title = value;
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: value }),
|
||
});
|
||
} catch (e) {
|
||
a.title = previous;
|
||
el.textContent = previous || 'Untitled';
|
||
say('Could not save the title: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
function magContentsToggleArticle(id) {
|
||
if (state.contentsSelected.has(id)) state.contentsSelected.delete(id);
|
||
else state.contentsSelected.add(id);
|
||
renderContents();
|
||
}
|
||
|
||
function magContentsSelectAllVisible() {
|
||
state.articles.forEach((a) => state.contentsSelected.add(a.id));
|
||
renderContents();
|
||
}
|
||
|
||
function magContentsClearSelection() {
|
||
state.contentsSelected.clear();
|
||
renderContents();
|
||
}
|
||
|
||
async function magContentsBulkStatus(status) {
|
||
const ids = Array.from(state.contentsSelected);
|
||
if (!ids.length) return;
|
||
const bar = $('mag-contents-bulkbar');
|
||
let done = 0;
|
||
for (const id of ids) {
|
||
if (bar) bar.querySelector('.mag-bulkbar-progress')?.replaceChildren(
|
||
document.createTextNode(`${status === 'approved' ? 'Approving' : 'Rejecting'} ${done + 1} of ${ids.length}…`));
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
} catch (e) { say('Could not update ' + id + ': ' + e.message, 'err'); }
|
||
done += 1;
|
||
}
|
||
state.contentsSelected.clear();
|
||
say(`${done} article${done === 1 ? '' : 's'} ${status}`, 'ok');
|
||
await magRefreshArticles();
|
||
renderContents();
|
||
}
|
||
|
||
function renderContentsBulkBar() {
|
||
const bar = $('mag-contents-bulkbar');
|
||
if (!bar) return;
|
||
const n = state.contentsSelected.size;
|
||
if (!state.contentsSelectMode) { bar.hidden = true; bar.innerHTML = ''; return; }
|
||
bar.hidden = false;
|
||
bar.innerHTML =
|
||
'<span class="mag-bulkbar-count">' + n + ' selected</span>' +
|
||
'<span class="mag-bulkbar-progress muted"></span>' +
|
||
'<div class="mag-bulkbar-actions">' +
|
||
'<button type="button" class="btn-sm" onclick="magContentsSelectAllVisible()">Select all</button>' +
|
||
'<button type="button" class="btn-sm" onclick="magContentsClearSelection()" ' + (n ? '' : 'disabled') + '>Clear</button>' +
|
||
'<button type="button" class="btn-sm" onclick="magContentsBulkStatus(\'rejected\')" ' + (n ? '' : 'disabled') + '>Reject selected</button>' +
|
||
'<button type="button" class="btn-sm primary" onclick="magContentsBulkStatus(\'approved\')" ' + (n ? '' : 'disabled') + '>Approve selected</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
// ── Standalone MP3 export (title/author/cover/lyrics tags) ─────────────────
|
||
// For listening away from this app entirely -- a phone's own music/podcast
|
||
// player, a car head unit -- not the in-browser sentence-by-sentence player.
|
||
async function magGenerateAllAudio() {
|
||
const voice = ($('mag-audio-voice')?.value || '').trim();
|
||
if (!voice) return say('Pick a narrator voice for the export first', 'err');
|
||
// Skip whatever already has audio -- resuming after an aborted or
|
||
// partial run (confirmed live: a magazine this size can genuinely take
|
||
// a while, and killing the job partway through, deliberately or via a
|
||
// container restart, is a real scenario, not an edge case) should not
|
||
// mean re-synthesising articles that are already done.
|
||
const missingArticles = state.articles.filter((a) => a.status === 'approved' && !a.has_audio);
|
||
if (!missingArticles.length) return say('Every approved article already has audio', '');
|
||
const missing = missingArticles.map((a) => a.id);
|
||
const pageCount = state.doc.pageCount || 0;
|
||
const btn = $('mag-audio-export-btn');
|
||
const status = $('mag-audio-export-status');
|
||
const progressWrap = $('mag-audio-export-progress-wrap');
|
||
const progressBar = $('mag-audio-export-progress');
|
||
const progressCount = $('mag-audio-export-count');
|
||
if (btn) btn.disabled = true;
|
||
if (progressWrap) progressWrap.hidden = false;
|
||
let jobId;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ voice, article_ids: missing }),
|
||
});
|
||
jobId = r.job_id;
|
||
} catch (e) {
|
||
if (btn) btn.disabled = false;
|
||
if (progressWrap) progressWrap.hidden = true;
|
||
return say('Could not start audio export: ' + e.message, 'err');
|
||
}
|
||
for (;;) {
|
||
let job;
|
||
try { job = await api('/api/jobs/' + jobId); }
|
||
catch (e) { if (btn) btn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); }
|
||
if (status) status.textContent = job.detail || 'Working…';
|
||
// The job's own detail text always leads with "N/Total" -- either
|
||
// "Article N/Total…" between articles, or "(N/Total) Title: sentence
|
||
// X/Y" while one is actively rendering. "Article 6 of 44" means little
|
||
// on its own; "page 20 of 204" is what actually says how far through
|
||
// the magazine this run has gotten, so N is mapped back to that
|
||
// article's own starting page wherever possible.
|
||
const m = /(\d+)\/(\d+)/.exec(job.detail || '');
|
||
if (m && progressBar && progressCount) {
|
||
const page = missingArticles[parseInt(m[1], 10) - 1]?.pages?.[0];
|
||
if (page && pageCount) {
|
||
progressBar.max = pageCount; progressBar.value = page;
|
||
progressCount.textContent = 'p. ' + page + ' / ' + pageCount;
|
||
} else {
|
||
progressBar.max = m[2]; progressBar.value = m[1];
|
||
progressCount.textContent = m[1] + ' / ' + m[2];
|
||
}
|
||
}
|
||
if (job.done) {
|
||
say(job.error ? 'Audio export failed: ' + job.error : (job.detail || 'Audio export done'), job.error ? 'err' : 'ok');
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
}
|
||
if (btn) btn.disabled = false;
|
||
if (status) status.textContent = '';
|
||
if (progressWrap) progressWrap.hidden = true;
|
||
await magRefreshArticles();
|
||
renderContents();
|
||
if (!$('mag-playlist')?.hidden) renderPlaylistTracks();
|
||
}
|
||
|
||
function magUpdateAudioZipLink() {
|
||
const link = $('mag-audio-zip-link');
|
||
if (!link) return;
|
||
const any = state.articles.some((a) => a.has_audio);
|
||
link.hidden = !any;
|
||
if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip';
|
||
}
|
||
|
||
// ── Review (Stage D) ─────────────────────────────────────────────────────────
|
||
|
||
async function magReview(articleId) {
|
||
const article = state.articles.find((a) => a.id === articleId);
|
||
if (!article) return;
|
||
state.article = article;
|
||
await ensureLayout();
|
||
// The whole review is one page at a time -- showing every page of a
|
||
// 10-page feature stacked at once was unreadable and made the boxes tiny.
|
||
state.reviewSpan = magReviewSpan(article);
|
||
state.reviewPage = article.pages[0] || state.reviewSpan[0] || null;
|
||
_magReviewChecked = null;
|
||
magShow('review');
|
||
renderReview();
|
||
}
|
||
|
||
async function ensureLayout() {
|
||
if (state.layout) return;
|
||
try {
|
||
state.layout = await api('/api/magazine/' + state.doc.id + '/layout');
|
||
} catch (e) {
|
||
say('Could not load layout: ' + e.message, 'err');
|
||
state.layout = { blocks: [], pages: [], page_count: 0 };
|
||
}
|
||
}
|
||
|
||
function blocksForPages(pages) {
|
||
const want = new Set(pages || []);
|
||
return (state.layout.blocks || [])
|
||
.filter((b) => want.has(b.page))
|
||
.sort((a, b) => a.page - b.page || a.reading_order - b.reading_order);
|
||
}
|
||
|
||
// Offer neighbouring pages too, so a boundary that started a page late (or a
|
||
// jump tail that should have been included) can be corrected without
|
||
// leaving the screen.
|
||
function magReviewSpan(article) {
|
||
if (!article.pages.length) return [];
|
||
return Array.from(new Set(article.pages.concat(
|
||
[article.pages[0] - 1, article.pages[article.pages.length - 1] + 1])))
|
||
.filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y);
|
||
}
|
||
|
||
function magReviewGoPage(page) {
|
||
if (!state.reviewSpan.includes(page)) return;
|
||
state.reviewPage = page;
|
||
renderReview();
|
||
}
|
||
|
||
function magReviewGoMagazinePage(page) {
|
||
const lastPage = Number(state.layout?.page_count || 0);
|
||
if (!Number.isInteger(page) || page < 1 || page > lastPage) return;
|
||
state.reviewPage = page;
|
||
renderReview();
|
||
}
|
||
|
||
// One glyph per region kind, shown centred in its box -- the fastest way to
|
||
// tell "a paragraph of text" from "a photo" from "furniture that will not be
|
||
// read" without reading the label. No table detection exists yet (P2), so a
|
||
// table currently reads as its own text region like any other paragraph.
|
||
// What a region can be changed into from the review list. Values match the server's REGION_TYPES.
|
||
// Mirrors core/magazine.py's NARRATABLE -- the kinds that are actually read
|
||
// aloud. A freshly drawn region of one of these is ticked in automatically
|
||
// (see magReviewSubmitDraw); everything else (page number, source, ad,
|
||
// table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture
|
||
// or reference material excluded from narration on purpose and stays unticked.
|
||
const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body']);
|
||
const MAG_REGION_TYPES = [
|
||
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'],
|
||
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
|
||
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'],
|
||
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'],
|
||
];
|
||
// A compact subset of MAG_REGION_TYPES for the review page's colour legend
|
||
// -- the full 16-entry list would be noisy here, and several of those kinds
|
||
// (source/in_picture/ocr_noise/duplicate) already render as plain grey
|
||
// "furniture" on the page itself, so listing them separately would promise
|
||
// a colour distinction the boxes don't actually make.
|
||
const MAG_LEGEND_TYPES = [
|
||
['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'],
|
||
['body', 'Paragraph'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
|
||
['ad', 'Advertisement'], ['furniture', 'Page number / footer'],
|
||
['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'],
|
||
];
|
||
|
||
function magReviewLegendHtml() {
|
||
return '<p class="mag-review-legend">' +
|
||
MAG_LEGEND_TYPES.map(([key, label]) =>
|
||
'<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + key + '"></span>' + label + '</span>'
|
||
).join('') + '</p>';
|
||
}
|
||
|
||
const MAG_REGION_TYPE_ICONS = {
|
||
body: 'mdi-text-box-outline', title: 'mdi-format-title', heading: 'mdi-format-header-2',
|
||
deck: 'mdi-text-short', byline: 'mdi-account-edit-outline', caption: 'mdi-image-text',
|
||
pullquote: 'mdi-format-quote-close', sidebar: 'mdi-note-text-outline', page_number: 'mdi-numeric',
|
||
source: 'mdi-book-open-page-variant-outline', ad: 'mdi-advertisements-outline',
|
||
in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline',
|
||
duplicate: 'mdi-content-duplicate', table: 'mdi-table', image: 'mdi-image-outline',
|
||
};
|
||
// Furniture kinds that the picker shows under their own name (everything else is a page number).
|
||
const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate'];
|
||
|
||
// The picker value for a region as it is now.
|
||
function magRegionTypeValue(region) {
|
||
if (region.kind === 'image') return 'image';
|
||
const b = region.data;
|
||
if (b.type === 'furniture') return MAG_FURNITURE_KINDS.includes(b.region_label) ? b.region_label : 'page_number';
|
||
return MAG_REGION_TYPES.some(([v]) => v === b.type) ? b.type : 'body';
|
||
}
|
||
|
||
// A type picker and a delete button for one row of the review list. Both stop the click from
|
||
// reaching the row, which is a <label>: a click inside it would otherwise toggle the checkbox.
|
||
function magRegionControls(region, number) {
|
||
const current = magRegionTypeValue(region);
|
||
const known = MAG_REGION_TYPES.some(([v]) => v === current);
|
||
const options = (known ? [] : [[current, current]]).concat(MAG_REGION_TYPES)
|
||
.map(([v, label]) => '<option value="' + v + '"' + (v === current ? ' selected' : '') + '>' + esc(label) + '</option>').join('');
|
||
return '<select class="mag-block-type-select" aria-label="Type of region ' + number + '" ' +
|
||
'onclick="event.stopPropagation()" onchange="magReviewChangeType(\'' + esc(region.id) + '\', this.value, this)">' +
|
||
options + '</select>' +
|
||
'<button type="button" class="btn-sm mag-block-delete" aria-label="Delete region ' + number + '" title="Delete this region" ' +
|
||
'onclick="event.preventDefault(); event.stopPropagation(); magReviewDeleteRegion(\'' + esc(region.id) + '\')">' +
|
||
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span></button>';
|
||
}
|
||
|
||
async function magReviewChangeType(regionId, type, select) {
|
||
const toImage = type === 'image';
|
||
const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId);
|
||
if (select) select.disabled = true;
|
||
say(fromImage && !toImage ? 'Reading the region as text…' : 'Changing region type…', 'info');
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(regionId), {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type }),
|
||
});
|
||
if (r && r.id && r.id !== regionId) magRemapSelection(regionId, r.id, toImage);
|
||
magOfferUndo(r?.undo, 'Region type changed.');
|
||
} catch (e) {
|
||
if (select) select.disabled = false;
|
||
return say('Could not change the region type: ' + e.message, 'err');
|
||
}
|
||
say('Region type changed', 'ok');
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
renderReview();
|
||
}
|
||
|
||
function magBlockIcon(type) {
|
||
if (type === 'image' || type === 'figure') return 'mdi-image-outline';
|
||
if (type === 'pullquote') return 'mdi-format-quote-close';
|
||
if (type === 'chart') return 'mdi-chart-bar';
|
||
if (type === 'table') return 'mdi-table';
|
||
if (type === 'code') return 'mdi-code-tags';
|
||
if (type === 'page_number') return 'mdi-numeric';
|
||
if (type === 'furniture' || type === 'ad') return 'mdi-close-box-outline';
|
||
if (type === 'caption') return 'mdi-image-text';
|
||
return 'mdi-text-box-outline';
|
||
}
|
||
|
||
// A page a feature spans can share space with a neighbouring piece (the
|
||
// tail of the previous article, the start of the next). Blocks belonging to
|
||
// the article being reviewed need no extra label -- their checkbox already
|
||
// says so -- but a block claimed by a DIFFERENT already-detected article is
|
||
// otherwise easy to mistake for stray, unclaimed text. Returns null for the
|
||
// current article or for a block no article has claimed.
|
||
function magBlockOwner(blockId) {
|
||
const current = state.article;
|
||
for (let i = 0; i < state.articles.length; i++) {
|
||
const art = state.articles[i];
|
||
if (art.id === current.id) continue;
|
||
if ((art.blocks || []).includes(blockId) || (art.images || []).includes(blockId)) {
|
||
return { index: i + 1, title: art.title };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Builds one page's worth of review UI -- the page image with its numbered
|
||
// boxes, and the matching OCR-results list -- so the same markup can be used
|
||
// for the single active page or, in spread mode, for either half of a
|
||
// two-page spread. Only `interactive` (always exactly one page at a time,
|
||
// the one the toolbar's draw/edit/OCR-again actions apply to) gets the fixed
|
||
// 'mag-review-page-wrap' id that setupMagBoxEdit/setupMagDrawing target and
|
||
// the draw-rectangle placeholder; the other page in a spread is shown for
|
||
// context with working checkboxes and click-to-focus, but its box outlines
|
||
// are not draggable.
|
||
function magReviewPageColumn(page, wrapId, interactive) {
|
||
const a = state.article;
|
||
const included = _magReviewChecked || new Set(a.blocks || []);
|
||
const ownImages = new Set(a.images || []);
|
||
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
|
||
|
||
const pageBlocks = page == null ? [] : blocksForPages([page]);
|
||
const pageImages = page == null ? [] : (state.layout?.images || [])
|
||
.filter((img) => img.page === page).sort((x, y) => (x.reading_order || 0) - (y.reading_order || 0));
|
||
// One merged, reading-order-sorted list so numbering on both sides matches
|
||
// regardless of whether an item is text or a figure.
|
||
const visualBlockIds = new Set(pageImages.map(i => i.source_block).filter(Boolean));
|
||
const regions = pageBlocks.filter(b => !visualBlockIds.has(b.id)).map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order ?? 0, data: b }))
|
||
.concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order ?? 0, data: img })))
|
||
.sort((x, y) => x.ro - y.ro);
|
||
if (interactive) state.reviewRegionOrder = regions.map((r) => r.id);
|
||
|
||
let pageHtml = '';
|
||
if (page != null) {
|
||
pageHtml = '<div class="mag-review-page-wrap' + (interactive ? '' : ' is-context-page') + '" id="' + wrapId + '">' +
|
||
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
|
||
(interactive ? '<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>' : '');
|
||
regions.forEach((r, i) => {
|
||
if (!pageMeta) return;
|
||
const bbox = r.data.bbox;
|
||
const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
|
||
const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
|
||
const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
|
||
const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
|
||
const isImage = r.kind === 'image';
|
||
const on = isImage ? ownImages.has(r.id) : included.has(r.id);
|
||
const owner = magBlockOwner(r.id);
|
||
const typeKey = isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type;
|
||
const icon = magBlockIcon(typeKey);
|
||
pageHtml += '<div class="mag-review-box mpt-' + typeKey + (on ? '' : ' is-excluded') + (owner ? ' has-owner' : '') + (r.data.type === 'ad' ? ' is-advertisement' : '') +
|
||
'" data-block="' + esc(r.id) + '" data-kind="' + (isImage ? 'image' : 'text') +
|
||
'" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h +
|
||
'%" onclick="magReviewFocusBlock(\'' + esc(r.id) + '\')" title="' + esc(isImage ? 'figure' : r.data.type) + '">' +
|
||
'<span class="mag-review-box-num">' + (i + 1) + '</span>' +
|
||
'<span class="mag-review-box-icon mdi ' + icon + '"></span>' +
|
||
(owner ? '<span class="mag-review-box-owner">Art. ' + owner.index + '</span>' : '') +
|
||
'<button type="button" class="mag-review-box-delete" tabindex="-1" ' +
|
||
'aria-label="Delete this region" title="Delete this region" ' +
|
||
'onclick="event.stopPropagation(); magReviewDeleteRegion(\'' + esc(r.id) + '\')">' +
|
||
'<span class="mdi mdi-close" aria-hidden="true"></span></button>' +
|
||
MAG_RESIZE_HANDLES +
|
||
'</div>';
|
||
});
|
||
pageHtml += '</div>';
|
||
} else {
|
||
pageHtml = '<p class="muted">No page to show.</p>';
|
||
}
|
||
|
||
let listHtml = '<div class="mag-blocks"' + (interactive
|
||
? ' onkeydown="magListKey(event)" onfocusin="magListFocus(event)" aria-describedby="mag-list-keys"' : '') + '>' +
|
||
(interactive ? '<p id="mag-list-keys" class="muted mag-list-keys">Keys: ↑/↓ move between regions · Space tick · X select · Enter edit text · R read again · M merge with the next region · Delete delete (Undo appears above)</p>' : '') +
|
||
(interactive ? magReviewLegendHtml() : '');
|
||
regions.forEach((r, i) => {
|
||
const isImage = r.kind === 'image';
|
||
const owner = magBlockOwner(r.id);
|
||
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
|
||
if (isImage) {
|
||
const on = ownImages.has(r.id);
|
||
const imgFilename = r.data.filename || (r.id + '.png');
|
||
// Resizing an image region re-crops the same filename in place (see
|
||
// update_region in core/magazine.py) -- an identical URL never
|
||
// re-fetches in the browser, so the old crop kept showing after a
|
||
// resize was saved. bbox changes exactly when the crop does, so it
|
||
// makes a cheap, correct cache-busting key.
|
||
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename) +
|
||
'?v=' + encodeURIComponent((r.data.bbox || []).join(','));
|
||
listHtml += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
|
||
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||
magOrderNumber(r.id, i + 1, regions.length, interactive) +
|
||
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
|
||
'<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</span>' +
|
||
'<div class="mag-block-thumb-wrap">' +
|
||
'<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' +
|
||
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title)
|
||
: on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '</span>' +
|
||
'</div>' +
|
||
magDescribeMarkup(r.id, r.data.description) +
|
||
'</div>';
|
||
return;
|
||
}
|
||
const b = r.data;
|
||
const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type);
|
||
const on = included.has(b.id);
|
||
listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
|
||
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||
magOrderNumber(b.id, i + 1, regions.length, interactive) +
|
||
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
|
||
'<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</span>' +
|
||
'<span class="mag-block-text' + (interactive ? ' is-editable' : '') + '"' +
|
||
// The whole row is a <label> wrapping the tick checkbox -- a plain
|
||
// click anywhere in it toggles that checkbox by default, so a click
|
||
// meant for editing must stop that before it fires. Bypasses the
|
||
// pencil-icon click magRowActions already offers; this is just a
|
||
// faster path to the exact same editor, not a replacement for it.
|
||
(interactive ? ' onclick="event.preventDefault();event.stopPropagation();magRowEdit(\'' + b.id + '\')" title="Click to edit"' : '') + '>' +
|
||
(owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
|
||
(b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') +
|
||
esc(b.text.slice(0, 220)) + (b.text.length > 220 ? '…' : '') + '</span>' +
|
||
(interactive ? magRowActions(b.id, i + 1, i + 1 < regions.length) : '') + '</label>';
|
||
});
|
||
if (!regions.length) listHtml += '<p class="muted">Nothing detected on this page.</p>';
|
||
listHtml += '</div>';
|
||
|
||
return { pageHtml, listHtml };
|
||
}
|
||
|
||
// The natural print-spread partner of a page (odd/even pairing, e.g. 22-23),
|
||
// falling back to the other neighbour when the natural one is out of range
|
||
// (covers, single-page inserts) so a spread is still offered near either end
|
||
// of the issue.
|
||
function magSpreadPartner(page, pageCount) {
|
||
if (page == null || !pageCount) return null;
|
||
const natural = (page % 2 === 0) ? page + 1 : page - 1;
|
||
if (natural >= 1 && natural <= pageCount) return natural;
|
||
const alt = (page % 2 === 0) ? page - 1 : page + 1;
|
||
return (alt >= 1 && alt <= pageCount && alt !== page) ? alt : null;
|
||
}
|
||
|
||
function magReviewToggleSpread() {
|
||
state.reviewSpread = !state.reviewSpread;
|
||
renderReview();
|
||
}
|
||
|
||
// Switches which half of a two-page spread the toolbar's draw/edit/OCR-again
|
||
// actions apply to, without leaving spread mode.
|
||
function magReviewSetActivePage(page) {
|
||
if (page === state.reviewPage) return;
|
||
state.reviewPage = page;
|
||
renderReview();
|
||
}
|
||
|
||
function renderReview() {
|
||
const host = $('mag-review');
|
||
const a = state.article;
|
||
if (!host || !a) return;
|
||
// A half-built click order belongs to the page it was started on.
|
||
if (state.orderMode && state.orderPage !== state.reviewPage) { state.orderMode = false; state.orderSeq = []; }
|
||
if (state.selectPage !== state.reviewPage) { state.selected = new Set(); state.selectPage = state.reviewPage; }
|
||
const conf = confidenceLabel(a.confidence);
|
||
const page = state.reviewPage;
|
||
const pageCount = Number(state.layout?.page_count || 0);
|
||
const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null;
|
||
const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null;
|
||
const issuePages = pageCount || '—';
|
||
const nextIssuePage = page != null && page < pageCount ? page + 1 : null;
|
||
const prevIssuePage = page != null && page > 1 ? page - 1 : null;
|
||
|
||
let html = '';
|
||
html += '<div class="mag-review-pagenav">' +
|
||
// One page stepper for the whole magazine, not a separate one bounded
|
||
// to just this article's own pages — the two almost always agreed and
|
||
// having both doubled the toolbar's width for no real benefit.
|
||
'<div class="mag-review-pagenav-group" role="group" aria-label="Magazine page">' +
|
||
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevIssuePage ? '' : 'disabled') +
|
||
' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
|
||
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
|
||
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextIssuePage ? '' : 'disabled') +
|
||
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
|
||
'<div class="mag-review-pagenav-group" role="group" aria-label="Spread view">' +
|
||
'<button type="button" class="btn-sm" id="mag-spread-toggle" aria-pressed="' + String(!!state.reviewSpread) + '" ' +
|
||
'title="Show this page next to its facing page, each with its own OCR results below" ' +
|
||
'onclick="magReviewToggleSpread()"><span class="mdi mdi-book-open-page-variant" aria-hidden="true"></span> ' +
|
||
(state.reviewSpread ? 'Single page' : 'Two-page spread') + '</button></div>' +
|
||
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
|
||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle" aria-pressed="false">' +
|
||
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw region' +
|
||
'<span class="mdi mdi-toggle-switch-off-outline mag-draw-toggle-switch" id="mag-draw-toggle-switch" aria-hidden="true"></span></button>' +
|
||
'<button type="button" class="btn-sm" id="mag-edit-toggle" aria-pressed="false" ' +
|
||
'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' +
|
||
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-select-toggle" aria-pressed="' + String(!!state.selectMode) + '" ' +
|
||
'title="Select several boxes -- click them or drag a rectangle over them -- then change their type or delete them at once" onclick="magSelectToggle()">' +
|
||
'<span class="mdi mdi-selection-drag" aria-hidden="true"></span> Select boxes</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' +
|
||
'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' +
|
||
'<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</button>' +
|
||
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button>' +
|
||
'<button type="button" class="btn-sm" onclick="magReviewRemoveAllBoxes()" title="Delete every detected box on this page, so you can draw the layout from scratch">' +
|
||
'<span class="mdi mdi-eraser" aria-hidden="true"></span> Remove all boxes</button></div>' +
|
||
'<span class="mag-review-pagenav-spacer"></span>' +
|
||
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
|
||
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
|
||
'<span class="mdi mdi-sort-numeric-ascending" aria-hidden="true"></span> Fix reading order</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-cleanup-btn" onclick="magCleanUpBoxes()" ' +
|
||
'title="Stop reading text inside pictures, OCR noise and duplicated boxes, and split author lines that run into the text. Nothing is deleted; a backup is kept.">' +
|
||
'<span class="mdi mdi-broom" aria-hidden="true"></span> Clean up boxes</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
|
||
'title="Re-check every figure boundary in this issue against the scanned pages">' +
|
||
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
|
||
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
|
||
'<span id="mag-reocr-status" class="muted" role="status"></span>' +
|
||
'</div>';
|
||
|
||
html += '<div class="mag-review-fields">';
|
||
html += '<div class="mag-review-field-row">' +
|
||
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
|
||
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
|
||
'</div>' +
|
||
'<span id="mag-draw-hint" class="muted"></span>' +
|
||
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>' +
|
||
'<div id="mag-select-bar" class="mag-order-bar" role="status" aria-live="polite">' + magSelectBarHtml() + '</div>' +
|
||
'<div id="mag-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</div>';
|
||
html += '</div>';
|
||
|
||
html += '<span class="mag-review-conf ' + conf.cls + '">' + esc(conf.text) + '</span>';
|
||
html += '<p class="muted">Untick anything that is not part of this article — a sidebar, an advert, or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, or its matching entry on the right, to line the two up. A region labelled with another article belongs to a different piece sharing this page. Draw a missing text or image region below.</p>';
|
||
|
||
if (state.layout.semantic_review === 'partial_failure') {
|
||
html += '<p class="muted" role="status">Some regions could not be grouped automatically. Check paragraph boundaries before approving.</p>';
|
||
}
|
||
if (state.layout.advertisement_review === 'partial_failure') {
|
||
html += '<p class="muted" role="status">Advertisement detection could not finish. Check this page for adverts before approving.</p>';
|
||
} else if (state.layout.advertisement_review === 'not_configured') {
|
||
html += '<p class="muted">Set a language model in Settings to recognise advertisements automatically.</p>';
|
||
}
|
||
|
||
if (spreadPages) {
|
||
// 2x2 spread: both pages' images on top, each page's own OCR-results
|
||
// list directly underneath it -- only the toolbar-active page (state.
|
||
// reviewPage) is draggable/drawable; the other is shown for context,
|
||
// with "Edit this page" swapping which one is active.
|
||
const cols = spreadPages.map((p) => ({
|
||
page: p,
|
||
interactive: p === page,
|
||
...magReviewPageColumn(p, p === page ? 'mag-review-page-wrap' : 'mag-review-page-wrap-alt', p === page),
|
||
}));
|
||
html += '<div class="mag-review-spread">';
|
||
cols.forEach((c) => {
|
||
html += '<div class="mag-review-spread-page' + (c.interactive ? ' is-active-page' : '') + '">' +
|
||
'<div class="mag-review-spread-pagehead"><strong>Page ' + c.page + '</strong>' +
|
||
(c.interactive ? ' <span class="mag-pill ok">Editing</span>'
|
||
: ' <button type="button" class="btn-sm" onclick="magReviewSetActivePage(' + c.page + ')">Edit this page</button>') +
|
||
'</div>' + c.pageHtml + '</div>';
|
||
});
|
||
cols.forEach((c) => {
|
||
html += '<div class="mag-review-spread-list"><h4 class="mag-page-head">OCR results — page ' + c.page + '</h4>' + c.listHtml + '</div>';
|
||
});
|
||
html += '</div>';
|
||
} else {
|
||
const col = magReviewPageColumn(page, 'mag-review-page-wrap', true);
|
||
html += '<div class="mag-review-split">' +
|
||
'<div class="mag-review-page">' + col.pageHtml + '</div>' +
|
||
col.listHtml +
|
||
'</div>';
|
||
}
|
||
|
||
html += '<div class="mag-review-foot">' +
|
||
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
|
||
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
|
||
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
|
||
'</div>';
|
||
host.innerHTML = html;
|
||
if (state.orderMode) {
|
||
$('mag-review-page-wrap')?.classList.add('is-ordering');
|
||
magOrderRefreshBar();
|
||
}
|
||
if (state.selectMode) magSelectRefresh();
|
||
setupMagDrawing();
|
||
setupMagBoxEdit();
|
||
magReviewAutofillTitleAuthor();
|
||
}
|
||
|
||
// A Headline or Author region belonging to this article is exactly what its
|
||
// own Title/Author field should say -- confirmed live, they matched
|
||
// perfectly for a headline detected at ingestion, but nothing kept them in
|
||
// sync afterward: retyping a region to Author, or drawing a new Headline
|
||
// region by hand, left the fields blank until "Pick from page" was used
|
||
// again by hand. Only fills an EMPTY field, so it never overwrites a
|
||
// value the reader already typed or picked deliberately.
|
||
function magReviewAutofillTitleAuthor() {
|
||
const article = state.article;
|
||
if (!article) return;
|
||
const titleInput = $('mag-rev-title');
|
||
const authorInput = $('mag-rev-author');
|
||
if ((!titleInput || titleInput.value.trim()) && (!authorInput || authorInput.value.trim())) return;
|
||
const ownIds = new Set(article.blocks || []);
|
||
const ownBlocks = (state.layout.blocks || []).filter((b) => ownIds.has(b.id));
|
||
if (titleInput && !titleInput.value.trim()) {
|
||
const block = ownBlocks.find((b) => b.type === 'title' && (b.text || '').trim());
|
||
if (block) { titleInput.value = block.text.trim(); article.title = titleInput.value; }
|
||
}
|
||
if (authorInput && !authorInput.value.trim()) {
|
||
const block = ownBlocks.find((b) => b.type === 'byline' && (b.text || '').trim());
|
||
// Same "Von "/"by " credit-prefix strip the initial ingestion pass uses
|
||
// (core/magazine.py's looks_like_byline) — approximated here since this
|
||
// runs client-side without a round trip.
|
||
const name = block ? block.text.replace(/^(von|by)\s+/i, '').trim() : '';
|
||
if (name) { authorInput.value = name; article.author = name; }
|
||
}
|
||
}
|
||
|
||
// Tracks every ticked block across ALL reviewed pages of this article, not
|
||
// just the one currently on screen -- switching pages must not forget a
|
||
// change made on a previous one.
|
||
let _magReviewChecked = null;
|
||
|
||
function magReviewToggleBox(input) {
|
||
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
|
||
if (input.checked) _magReviewChecked.add(input.dataset.block);
|
||
else _magReviewChecked.delete(input.dataset.block);
|
||
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(input.dataset.block) + '"]');
|
||
if (box) box.classList.toggle('is-excluded', !input.checked);
|
||
}
|
||
|
||
function magReviewToggleImage(id, checked) {
|
||
const selected = new Set(state.article.images || []);
|
||
if (checked) selected.add(id); else selected.delete(id);
|
||
state.article.images = Array.from(selected);
|
||
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(id) + '"]');
|
||
if (box) box.classList.toggle('is-excluded', !checked);
|
||
const row = document.querySelector('[data-block-row="' + CSS.escape(id) + '"]');
|
||
if (row) {
|
||
row.classList.toggle('is-aux', !checked);
|
||
row.querySelector('.mag-block-text').textContent = checked ? 'Shown during playback for this article.' : 'Not included in playback for this article.';
|
||
}
|
||
}
|
||
|
||
// ── Figure descriptions ──────────────────────────────────────────────────
|
||
// Purely additive: generating or editing a description never changes
|
||
// whether the image itself is shown or included (see magReviewToggleImage
|
||
// above) -- it only supplies the text "Read figure descriptions" narrates
|
||
// if the reader turns that on. Never spoken by itself.
|
||
function magDescribeMarkup(id, description) {
|
||
const safeId = esc(id);
|
||
const has = !!(description || '').trim();
|
||
return '<div class="mag-describe">' +
|
||
'<button type="button" class="btn-sm" onclick="magDescribeImage(\'' + safeId + '\')" id="mag-describe-btn-' + safeId + '">' +
|
||
'<span class="mdi mdi-text-to-speech" aria-hidden="true"></span> ' + (has ? 'Redo description' : 'Describe figure') +
|
||
'</button>' +
|
||
'<span class="muted" id="mag-describe-status-' + safeId + '" role="status"></span>' +
|
||
'<label class="mag-describe-label" for="mag-describe-text-' + safeId + '">Spoken description ' +
|
||
'<span class="muted">(only read aloud if "Read figure descriptions" is on)</span></label>' +
|
||
'<textarea class="mag-describe-text" id="mag-describe-text-' + safeId + '" rows="2" ' +
|
||
'placeholder="No description yet." onchange="magSaveImageDescription(\'' + safeId + '\', this)">' +
|
||
esc(description || '') + '</textarea>' +
|
||
'</div>';
|
||
}
|
||
|
||
async function magDescribeImage(id) {
|
||
const btn = $('mag-describe-btn-' + id);
|
||
const statusEl = $('mag-describe-status-' + id);
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.textContent = 'Describing…';
|
||
let jobId;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/images/' + id + '/describe', { method: 'POST' });
|
||
jobId = r.job_id;
|
||
} catch (e) {
|
||
if (btn) btn.disabled = false;
|
||
if (statusEl) statusEl.textContent = '';
|
||
return say('Could not start: ' + e.message, 'err');
|
||
}
|
||
let result = null, failed = false;
|
||
for (;;) {
|
||
let job;
|
||
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
|
||
if (job.done) {
|
||
if (job.error) { failed = true; say('Description failed: ' + job.error, 'err'); }
|
||
else { result = job.result; say('Figure described', 'ok'); }
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
}
|
||
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="mdi mdi-text-to-speech" aria-hidden="true"></span> Redo description'; }
|
||
if (statusEl) statusEl.textContent = '';
|
||
if (failed || !result) return;
|
||
const textarea = $('mag-describe-text-' + id);
|
||
if (textarea) textarea.value = result.description || '';
|
||
const layoutImg = (state.layout?.images || []).find((i) => i.id === id);
|
||
if (layoutImg) layoutImg.description = result.description || '';
|
||
}
|
||
|
||
async function magSaveImageDescription(id, textarea) {
|
||
const statusEl = $('mag-describe-status-' + id);
|
||
if (statusEl) statusEl.textContent = 'Saving…';
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/images/' + id, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ description: textarea.value }),
|
||
});
|
||
const layoutImg = (state.layout?.images || []).find((i) => i.id === id);
|
||
if (layoutImg) layoutImg.description = textarea.value;
|
||
if (statusEl) statusEl.textContent = 'Saved';
|
||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = '';
|
||
say('Could not save description: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
function magReviewClearCurrentPage() {
|
||
if (state.reviewPage == null) return;
|
||
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
|
||
const page = state.reviewPage;
|
||
(state.layout.blocks || []).filter((block) => block.page === page)
|
||
.forEach((block) => _magReviewChecked.delete(block.id));
|
||
const imageIds = new Set((state.layout.images || []).filter((image) => image.page === page).map((image) => image.id));
|
||
state.article.images = (state.article.images || []).filter((id) => !imageIds.has(id));
|
||
renderReview();
|
||
say('All fields on this page cleared — select only the article content to keep.', 'ok');
|
||
}
|
||
|
||
// "Clear all fields" above only deselects a page's regions from narration --
|
||
// the boxes themselves, right or wrong, stay. When the detected layout on a
|
||
// page is too tangled to fix by editing individual boxes, this deletes every
|
||
// region on the page outright so "Draw region" can rebuild it from nothing.
|
||
// Goes through the same bulk endpoint as multi-select delete, so it gets the
|
||
// same one-shot Undo.
|
||
async function magReviewRemoveAllBoxes() {
|
||
if (state.reviewPage == null) return;
|
||
const page = state.reviewPage;
|
||
const ids = [
|
||
...(state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id),
|
||
...(state.layout.images || []).filter((im) => im.page === page).map((im) => im.id),
|
||
];
|
||
if (!ids.length) return say('No boxes on this page to remove', '');
|
||
const ok = await confirmDialog(
|
||
`Delete all ${ids.length} box${ids.length === 1 ? '' : 'es'} on page ${page}? ` +
|
||
'This can be undone with the Undo button that appears after.',
|
||
{ title: 'Remove all boxes?', okLabel: 'Remove all' });
|
||
if (!ok) return;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/regions/bulk', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids, action: 'delete' }),
|
||
});
|
||
magOfferUndo(r?.undo, `Removed ${ids.length} box${ids.length === 1 ? '' : 'es'}.`);
|
||
} catch (e) {
|
||
return say('Could not remove boxes: ' + e.message, 'err');
|
||
}
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
renderReview();
|
||
say('All boxes on this page removed', 'ok');
|
||
}
|
||
|
||
// ── Reading order by hand ─────────────────────────────────────────────────
|
||
// The number in front of each row is its reading position. Typing a new number moves the region
|
||
// there; "Order by clicking" lets the reader click the boxes on the page in reading order. Either
|
||
// way the page is then locked against automatic reordering until "Automatic order" is pressed.
|
||
|
||
function magOrderNumber(id, number, total, editable) {
|
||
if (!editable) return '<span class="mag-block-num">' + number + '</span>';
|
||
return '<input type="number" class="mag-block-num mag-block-order" min="1" max="' + total + '" value="' + number + '" ' +
|
||
'aria-label="Reading position of region ' + number + ' (1 to ' + total + ')" ' +
|
||
'onclick="event.stopPropagation()" onkeydown="if (event.key === \'Enter\') { event.preventDefault(); this.blur(); }" ' +
|
||
'onchange="magOrderMove(\'' + esc(id) + '\', this.value)">';
|
||
}
|
||
|
||
function magReviewPageLocked() {
|
||
const meta = (state.layout?.pages || []).find((p) => p.page === state.reviewPage);
|
||
return !!meta?.order_locked;
|
||
}
|
||
|
||
function magOrderBarHtml() {
|
||
if (state.orderMode) {
|
||
const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length;
|
||
return '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
|
||
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
|
||
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' +
|
||
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button> ' +
|
||
'<button type="button" class="btn-sm" onclick="magOrderToggle()">Cancel</button>';
|
||
}
|
||
if (magReviewPageLocked()) {
|
||
return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' +
|
||
'<button type="button" class="btn-sm" onclick="magOrderReset()">Automatic order</button>';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function magOrderRefreshBar() {
|
||
const bar = $('mag-order-bar');
|
||
if (bar) bar.innerHTML = magOrderBarHtml();
|
||
document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => {
|
||
const at = (state.orderSeq || []).indexOf(box.dataset.block);
|
||
box.classList.toggle('is-ordered', state.orderMode && at >= 0);
|
||
const num = box.querySelector('.mag-review-box-num');
|
||
if (num && state.orderMode) num.textContent = at >= 0 ? String(at + 1) : '·';
|
||
});
|
||
}
|
||
|
||
function magOrderToggle() {
|
||
state.orderMode = !state.orderMode;
|
||
state.orderSeq = [];
|
||
state.orderPage = state.reviewPage;
|
||
if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind);
|
||
if (state.orderMode && state.editMode) magReviewToggleEdit();
|
||
const btn = $('mag-order-toggle');
|
||
if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); }
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (wrap) wrap.classList.toggle('is-ordering', state.orderMode);
|
||
if (!state.orderMode) return renderReview(); // restore the real numbers
|
||
magOrderRefreshBar();
|
||
}
|
||
|
||
function magOrderPick(id) {
|
||
const seq = state.orderSeq || (state.orderSeq = []);
|
||
if (!seq.includes(id)) seq.push(id);
|
||
magOrderRefreshBar();
|
||
}
|
||
|
||
function magOrderUndo() {
|
||
(state.orderSeq || []).pop();
|
||
magOrderRefreshBar();
|
||
}
|
||
|
||
async function magOrderPut(order) {
|
||
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }),
|
||
});
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
}
|
||
|
||
async function magOrderSave() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const seq = (state.orderSeq || []).slice();
|
||
if (!seq.length) return;
|
||
try {
|
||
await magOrderPut(seq);
|
||
} catch (e) {
|
||
return say('Could not save the reading order: ' + e.message, 'err');
|
||
}
|
||
state.orderMode = false;
|
||
state.orderSeq = [];
|
||
say('Reading order saved for this page', 'ok');
|
||
renderReview();
|
||
}
|
||
|
||
async function magOrderMove(id, value) {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const order = (state.reviewRegionOrder || []).filter((x) => x !== id);
|
||
const to = Math.min(Math.max(1, parseInt(value, 10) || 1), order.length + 1) - 1;
|
||
order.splice(to, 0, id);
|
||
try {
|
||
await magOrderPut(order);
|
||
} catch (e) {
|
||
say('Could not change the reading order: ' + e.message, 'err');
|
||
return renderReview();
|
||
}
|
||
say('Moved to position ' + (to + 1), 'ok');
|
||
renderReview();
|
||
}
|
||
|
||
async function magOrderReset() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', { method: 'DELETE' });
|
||
} catch (e) {
|
||
return say('Could not reset the reading order: ' + e.message, 'err');
|
||
}
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
say('Automatic reading order restored for this page', 'ok');
|
||
renderReview();
|
||
}
|
||
|
||
// ── Select several boxes, act once; undo instead of confirming ─────────────
|
||
|
||
function magSelectBarHtml() {
|
||
if (!state.selectMode) return '';
|
||
const n = (state.selected || new Set()).size;
|
||
const options = MAG_REGION_TYPES.map(([v, label]) => '<option value="' + v + '">' + esc(label) + '</option>').join('');
|
||
return '<span class="mdi mdi-selection-drag" aria-hidden="true"></span> ' +
|
||
(n ? n + ' selected.' : 'Click boxes on the page, or drag a rectangle over them, to select them.') + ' ' +
|
||
'<select id="mag-bulk-type" aria-label="Change the type of the selected boxes"' + (n ? '' : ' disabled') + '>' +
|
||
'<option value="">Change type to…</option>' + options + '</select> ' +
|
||
'<button type="button" class="btn-sm" onclick="magBulk(\'delete\')"' + (n ? '' : ' disabled') + '>' +
|
||
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete ' + (n || '') + '</button> ' +
|
||
'<button type="button" class="btn-sm" onclick="magSelectNone()"' + (n ? '' : ' disabled') + '>Select none</button> ' +
|
||
'<button type="button" class="btn-sm" onclick="magSelectToggle()">Done</button>';
|
||
}
|
||
|
||
function magSelectRefresh() {
|
||
const bar = $('mag-select-bar');
|
||
if (bar) {
|
||
bar.innerHTML = magSelectBarHtml();
|
||
$('mag-bulk-type')?.addEventListener('change', (e) => { if (e.target.value) magBulk('type', e.target.value); });
|
||
}
|
||
const sel = state.selected || new Set();
|
||
$('mag-review-page-wrap')?.classList.toggle('is-selecting', !!state.selectMode);
|
||
document.querySelectorAll('#mag-review [data-block], #mag-review [data-block-row]').forEach((el) => {
|
||
const id = el.dataset.block || el.dataset.blockRow;
|
||
if (el.matches('.mag-review-box, [data-block-row]')) el.classList.toggle('is-selected', !!state.selectMode && sel.has(id));
|
||
});
|
||
}
|
||
|
||
function magSelectToggle() {
|
||
state.selectMode = !state.selectMode;
|
||
state.selected = new Set();
|
||
state.selectPage = state.reviewPage;
|
||
if (state.selectMode && state.drawMode) magReviewToggleDraw(state.drawKind);
|
||
if (state.selectMode && state.editMode) magReviewToggleEdit();
|
||
if (state.selectMode && state.orderMode) magOrderToggle();
|
||
const btn = $('mag-select-toggle');
|
||
if (btn) { btn.classList.toggle('active', state.selectMode); btn.setAttribute('aria-pressed', String(state.selectMode)); }
|
||
magSelectRefresh();
|
||
}
|
||
|
||
function magSelectToggleOne(id) {
|
||
const sel = state.selected || (state.selected = new Set());
|
||
if (sel.has(id)) sel.delete(id); else sel.add(id);
|
||
magSelectRefresh();
|
||
}
|
||
|
||
function magSelectNone() {
|
||
state.selected = new Set();
|
||
magSelectRefresh();
|
||
}
|
||
|
||
// Boxes at least half inside the dragged rectangle (all values in % of the page).
|
||
function magSelectInRect(left, top, width, height) {
|
||
const sel = state.selected || (state.selected = new Set());
|
||
document.querySelectorAll('#mag-review-page-wrap .mag-review-box[data-block]').forEach((box) => {
|
||
const bl = parseFloat(box.style.left), bt = parseFloat(box.style.top);
|
||
const bw = parseFloat(box.style.width), bh = parseFloat(box.style.height);
|
||
const ix = Math.max(0, Math.min(bl + bw, left + width) - Math.max(bl, left));
|
||
const iy = Math.max(0, Math.min(bt + bh, top + height) - Math.max(bt, top));
|
||
if (bw > 0 && bh > 0 && ix * iy >= 0.5 * bw * bh) sel.add(box.dataset.block);
|
||
});
|
||
magSelectRefresh();
|
||
}
|
||
|
||
async function magBulk(action, type) {
|
||
const ids = Array.from(state.selected || []);
|
||
if (!ids.length) return;
|
||
let r;
|
||
try {
|
||
r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/bulk', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(action === 'type' ? { ids, action, type } : { ids, action }),
|
||
});
|
||
} catch (e) {
|
||
return say('Could not change the selected boxes: ' + e.message, 'err');
|
||
}
|
||
Object.entries(r.remap || {}).forEach(([oldId, newId]) => magRemapSelection(oldId, newId, type === 'image'));
|
||
state.selected = new Set();
|
||
magOfferUndo(r.undo, (action === 'delete' ? 'Deleted ' : 'Changed ') + r.count + ' box' + (r.count === 1 ? '' : 'es') + '.');
|
||
await magReloadReview();
|
||
}
|
||
|
||
function magUndoBarHtml() {
|
||
if (!state.undo) return '';
|
||
return '<span class="mdi mdi-undo-variant" aria-hidden="true"></span> ' + esc(state.undo.label) + ' ' +
|
||
'<button type="button" class="btn-sm" onclick="magUndo()">Undo</button>';
|
||
}
|
||
|
||
function magOfferUndo(token, label) {
|
||
state.undo = token ? { token, label } : null;
|
||
const bar = $('mag-undo-bar');
|
||
if (bar) bar.innerHTML = magUndoBarHtml();
|
||
}
|
||
|
||
async function magUndo() {
|
||
const undo = state.undo;
|
||
if (!undo) return;
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/undo', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: undo.token }),
|
||
});
|
||
} catch (e) {
|
||
state.undo = null;
|
||
magOfferUndo(null);
|
||
return say('Could not undo: ' + e.message, 'err');
|
||
}
|
||
state.undo = null;
|
||
// The article itself was restored too: take it (and its ticks) from the server again.
|
||
const articleId = state.article?.id;
|
||
await magRefreshArticles();
|
||
state.article = (state.articles || []).find((a) => a.id === articleId) || state.article;
|
||
_magReviewChecked = null;
|
||
await magReloadReview('Undone');
|
||
}
|
||
|
||
function magReviewFocusBlock(blockId) {
|
||
if (state.orderMode) return magOrderPick(blockId);
|
||
if (state.selectMode) return magSelectToggleOne(blockId);
|
||
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
|
||
if (state.drawMode || state.editMode) return;
|
||
if (cb) { cb.checked = !cb.checked; magReviewToggleBox(cb); }
|
||
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(blockId) + '"]');
|
||
if (row) row.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||
}
|
||
|
||
function magReviewHighlightBox(blockId) {
|
||
document.querySelectorAll('.mag-review-box.is-linked').forEach((el) => el.classList.remove('is-linked'));
|
||
if (!blockId) return;
|
||
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(blockId) + '"]');
|
||
if (box) box.classList.add('is-linked');
|
||
}
|
||
|
||
// ── Draw a region by hand ────────────────────────────────────────────────
|
||
// Escape hatch for when the OCR/layout pipeline missed a region entirely --
|
||
// not for correcting one it already found (tick/untick or "OCR again"
|
||
// cover that). Percent-of-image coordinates while dragging, converted to
|
||
// PDF-point page coordinates (matching every other bbox in the layout) only
|
||
// once the user commits the region.
|
||
// Applies state.drawMode/drawKind to the DOM -- pulled out of
|
||
// magReviewToggleDraw so magReviewSubmitDraw can re-assert the same "still
|
||
// drawing" visuals after renderReview() rebuilds the toolbar from scratch
|
||
// (which has no memory of drawMode, since that class was only ever applied
|
||
// imperatively, never baked into the template).
|
||
function _magSyncDrawUi() {
|
||
const hint = $('mag-draw-hint');
|
||
const field = state.drawKind === 'title' ? 'title' : state.drawKind === 'author' ? 'author' : '';
|
||
if (hint) hint.textContent = state.drawMode
|
||
? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.`
|
||
: ' Drag around the region, then say what it is. Press Escape to cancel.') : '';
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
|
||
const btn = $('mag-draw-toggle');
|
||
if (btn) { btn.classList.toggle('active', state.drawMode); btn.setAttribute('aria-pressed', String(state.drawMode)); }
|
||
// A background-colour change alone was easy to miss once several regions
|
||
// in a row had been added -- confirmed live. An explicit on/off switch
|
||
// icon is a second, unambiguous cue for the same state.
|
||
const switchIcon = $('mag-draw-toggle-switch');
|
||
if (switchIcon) {
|
||
switchIcon.classList.toggle('mdi-toggle-switch', state.drawMode);
|
||
switchIcon.classList.toggle('mdi-toggle-switch-off-outline', !state.drawMode);
|
||
}
|
||
}
|
||
|
||
function magReviewToggleDraw(kind = 'text') {
|
||
state.drawMode = state.drawMode && state.drawKind === kind ? false : true;
|
||
state.drawKind = kind;
|
||
if (state.drawMode && state.editMode) magReviewToggleEdit();
|
||
_magSyncDrawUi();
|
||
magReviewCancelDrawPanel();
|
||
}
|
||
|
||
// ── Edit an already-detected region ──────────────────────────────────────
|
||
// Correcting a box the layout engine already found -- drag its body to
|
||
// move it, its corner handle to resize it, or its × to delete it entirely.
|
||
// Mutually exclusive with draw mode (adding a region that's missing) for
|
||
// the same reason those drawing tools already dim every existing box while
|
||
// active: dragging and click-to-toggle-inclusion would otherwise fight over
|
||
// the same mousedown.
|
||
function magReviewToggleEdit() {
|
||
state.editMode = !state.editMode;
|
||
if (state.editMode && state.drawMode) magReviewToggleDraw(state.drawKind);
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (wrap) wrap.classList.toggle('is-editing', state.editMode);
|
||
const btn = $('mag-edit-toggle');
|
||
if (btn) { btn.classList.toggle('active', state.editMode); btn.setAttribute('aria-pressed', String(state.editMode)); }
|
||
const hint = $('mag-draw-hint');
|
||
if (hint) hint.textContent = state.editMode
|
||
? ' Drag a region to move it, its corner to resize it, or press × to delete it.' : '';
|
||
magReviewCancelEditPanel();
|
||
}
|
||
|
||
// A handle per edge/corner so any one boundary (e.g. the line between two
|
||
// stacked paragraph blocks) can be dragged on its own, instead of only the
|
||
// bottom-right corner moving width and height together.
|
||
const MAG_RESIZE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'].map((dir) =>
|
||
'<span class="mag-review-box-resize rz-' + dir + '" data-dir="' + dir + '" aria-hidden="true"></span>').join('');
|
||
|
||
let _magEditState = null; // { blockId, mode: 'move'|'resize', dir, box, startXPct, startYPct, origLeft, origTop, origW, origH }
|
||
|
||
function setupMagBoxEdit() {
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (!wrap) return;
|
||
if (state.editMode) wrap.classList.add('is-editing');
|
||
// wrap is rebuilt fresh by every renderReview(), so per-wrap listeners
|
||
// never accumulate -- only the one-time window-level listener below needs
|
||
// the install guard, matching setupMagDrawing's own pattern.
|
||
wrap.addEventListener('mousedown', (e) => {
|
||
if (!state.editMode || e.button !== 0) return;
|
||
if (e.target.closest('.mag-edit-panel')) return;
|
||
const resizeHandle = e.target.closest('.mag-review-box-resize');
|
||
const box = e.target.closest('.mag-review-box');
|
||
if (!box || box.id === 'mag-draw-rect') return;
|
||
magReviewCancelEditPanel();
|
||
const rect = wrap.getBoundingClientRect();
|
||
_magEditState = {
|
||
blockId: box.dataset.block,
|
||
mode: resizeHandle ? 'resize' : 'move',
|
||
dir: resizeHandle ? resizeHandle.dataset.dir : null,
|
||
box,
|
||
startXPct: (e.clientX - rect.left) / rect.width * 100,
|
||
startYPct: (e.clientY - rect.top) / rect.height * 100,
|
||
origLeft: parseFloat(box.style.left) || 0,
|
||
origTop: parseFloat(box.style.top) || 0,
|
||
origW: parseFloat(box.style.width) || 0,
|
||
origH: parseFloat(box.style.height) || 0,
|
||
};
|
||
e.preventDefault();
|
||
});
|
||
const MAG_RESIZE_MIN_PCT = 1;
|
||
wrap.addEventListener('mousemove', (e) => {
|
||
if (!_magEditState) return;
|
||
const rect = wrap.getBoundingClientRect();
|
||
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
|
||
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
|
||
const dx = xPct - _magEditState.startXPct;
|
||
const dy = yPct - _magEditState.startYPct;
|
||
const { box, origLeft, origTop, origW, origH, dir } = _magEditState;
|
||
if (_magEditState.mode === 'move') {
|
||
const left = Math.max(0, Math.min(100 - origW, origLeft + dx));
|
||
const top = Math.max(0, Math.min(100 - origH, origTop + dy));
|
||
box.style.left = left + '%'; box.style.top = top + '%';
|
||
} else {
|
||
// Each handle only moves the edges named in its direction, so e.g. the
|
||
// "n" (top) handle can pull the boundary between two stacked regions
|
||
// up or down without disturbing either one's width or opposite edge.
|
||
let left = origLeft, top = origTop, w = origW, h = origH;
|
||
if (dir.includes('w')) {
|
||
const clampedDx = Math.max(-origLeft, Math.min(origW - MAG_RESIZE_MIN_PCT, dx));
|
||
left = origLeft + clampedDx; w = origW - clampedDx;
|
||
} else if (dir.includes('e')) {
|
||
w = Math.max(MAG_RESIZE_MIN_PCT, Math.min(100 - origLeft, origW + dx));
|
||
}
|
||
if (dir.includes('n')) {
|
||
const clampedDy = Math.max(-origTop, Math.min(origH - MAG_RESIZE_MIN_PCT, dy));
|
||
top = origTop + clampedDy; h = origH - clampedDy;
|
||
} else if (dir.includes('s')) {
|
||
h = Math.max(MAG_RESIZE_MIN_PCT, Math.min(100 - origTop, origH + dy));
|
||
}
|
||
box.style.left = left + '%'; box.style.top = top + '%';
|
||
box.style.width = w + '%'; box.style.height = h + '%';
|
||
}
|
||
});
|
||
if (!_magEditMouseupInstalled) {
|
||
_magEditMouseupInstalled = true;
|
||
window.addEventListener('mouseup', () => {
|
||
if (!_magEditState) return;
|
||
const { box, origLeft, origTop, origW, origH } = _magEditState;
|
||
const moved = Math.abs((parseFloat(box.style.left) || 0) - origLeft) > .1 ||
|
||
Math.abs((parseFloat(box.style.top) || 0) - origTop) > .1 ||
|
||
Math.abs((parseFloat(box.style.width) || 0) - origW) > .1 ||
|
||
Math.abs((parseFloat(box.style.height) || 0) - origH) > .1;
|
||
const state_ = _magEditState;
|
||
_magEditState = null;
|
||
if (!moved) return; // a click, not a drag -- nothing to confirm
|
||
magReviewOpenEditPanel(state_.blockId, box, { origLeft, origTop, origW, origH });
|
||
});
|
||
}
|
||
}
|
||
let _magEditMouseupInstalled = false;
|
||
|
||
function magReviewCancelEditPanel(revert = false) {
|
||
const panel = document.querySelector('.mag-edit-panel');
|
||
if (panel) {
|
||
if (revert && panel.dataset.origLeft !== undefined) {
|
||
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(panel.dataset.blockId) + '"]');
|
||
if (box) {
|
||
box.style.left = panel.dataset.origLeft + '%'; box.style.top = panel.dataset.origTop + '%';
|
||
box.style.width = panel.dataset.origW + '%'; box.style.height = panel.dataset.origH + '%';
|
||
}
|
||
}
|
||
panel.remove();
|
||
}
|
||
}
|
||
|
||
function magReviewOpenEditPanel(blockId, box, orig) {
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (!wrap) return;
|
||
const kind = box.dataset.kind;
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mag-draw-panel mag-edit-panel';
|
||
panel.dataset.blockId = blockId;
|
||
panel.dataset.origLeft = orig.origLeft; panel.dataset.origTop = orig.origTop;
|
||
panel.dataset.origW = orig.origW; panel.dataset.origH = orig.origH;
|
||
panel.innerHTML =
|
||
'<div class="mag-draw-panel-row"><strong>Save this region’s new position?</strong></div>' +
|
||
(kind === 'text'
|
||
? '<p class="muted">Save keeps the existing text. Save & re-OCR reads the corrected area again and replaces it.</p>'
|
||
: '<p class="muted">The saved figure image will be re-cropped to the new box.</p>') +
|
||
'<div class="mag-draw-panel-row">' +
|
||
'<button type="button" class="btn-sm" onclick="magReviewCancelEditPanel(true)">Cancel</button>' +
|
||
(kind === 'text' ? '<button type="button" class="btn-sm" onclick="magReviewSubmitEdit(false)">Save</button>' +
|
||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(true)"><span class="mdi mdi-text-recognition"></span> Save & re-OCR</button>'
|
||
: '<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(false)">Save</button>') +
|
||
'</div>';
|
||
wrap.appendChild(panel);
|
||
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
|
||
panel.style.top = Math.min(80, top + height + 1) + '%';
|
||
panel.style.left = Math.min(55, left) + '%';
|
||
}
|
||
|
||
async function magReviewSubmitEdit(reocr) {
|
||
const panel = document.querySelector('.mag-edit-panel');
|
||
if (!panel) return;
|
||
const blockId = panel.dataset.blockId;
|
||
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(blockId) + '"]');
|
||
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
|
||
if (!box || !pageMeta) { magReviewCancelEditPanel(); return; }
|
||
const bbox = [
|
||
parseFloat(box.style.left) / 100 * pageMeta.width,
|
||
parseFloat(box.style.top) / 100 * pageMeta.height,
|
||
parseFloat(box.style.width) / 100 * pageMeta.width,
|
||
parseFloat(box.style.height) / 100 * pageMeta.height,
|
||
];
|
||
panel.remove();
|
||
say(reocr ? 'Saving and re-reading region…' : 'Saving region…', '');
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bbox, reocr }),
|
||
});
|
||
} catch (e) {
|
||
say('Could not save region: ' + e.message, 'err');
|
||
magReviewCancelEditPanel(true);
|
||
return;
|
||
}
|
||
say('Region saved', 'ok');
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
renderReview();
|
||
}
|
||
|
||
async function magCleanUpBoxes() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' +
|
||
'and boxes that repeat a bigger box\'s text stop being read aloud, and author lines that run into the first sentence are split. ' +
|
||
'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.',
|
||
{ title: 'Clean up boxes?', okLabel: 'Clean up' });
|
||
if (!ok) return;
|
||
const btn = document.getElementById('mag-cleanup-btn');
|
||
if (btn) btn.disabled = true;
|
||
let r;
|
||
try {
|
||
r = await api('/api/magazine/' + state.doc.id + '/boxes/clean-up', { method: 'POST' });
|
||
} catch (e) {
|
||
if (btn) btn.disabled = false;
|
||
return say('Could not clean up the boxes: ' + e.message, 'err');
|
||
}
|
||
const c = r.counts || {};
|
||
const parts = [[c.in_picture, 'text in pictures'], [c.ocr_noise, 'OCR noise'], [c.duplicate, 'duplicates'], [c.byline_split, 'author lines split']]
|
||
.filter(([n]) => n).map(([n, what]) => n + ' ' + what);
|
||
say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok');
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
renderReview();
|
||
}
|
||
|
||
async function magFixReadingOrder() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const ok = await confirmDialog('Re-read the reading order of every page in this issue, column by column. ' +
|
||
'Text, boxes and approvals stay as they are, but the order in which approved articles are read aloud can change. ' +
|
||
'Pages you ordered by hand are kept.', { title: 'Fix reading order?', okLabel: 'Fix reading order' });
|
||
if (!ok) return;
|
||
const btn = document.getElementById('mag-reading-order-btn');
|
||
if (btn) btn.disabled = true;
|
||
let r;
|
||
try {
|
||
r = await api('/api/magazine/' + state.doc.id + '/reading-order', { method: 'POST' });
|
||
} catch (e) {
|
||
if (btn) btn.disabled = false;
|
||
return say('Could not fix the reading order: ' + e.message, 'err');
|
||
}
|
||
const n = (r.pages_changed || []).length;
|
||
say(n ? 'Reading order fixed on ' + n + ' page' + (n === 1 ? '' : 's') +
|
||
(r.pages_locked ? ' (' + r.pages_locked + ' hand-ordered page' + (r.pages_locked === 1 ? '' : 's') + ' kept)' : '') + '.'
|
||
: 'The reading order was already right on every page.', 'ok');
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
renderReview();
|
||
}
|
||
|
||
// ── Quick corrections from the review list ────────────────────────────────
|
||
// Edit a region's text in place (and split it at the cursor), read it again, merge it with the next
|
||
// region, and move between regions with the keyboard -- without leaving the page.
|
||
|
||
function magRowActions(id, number, hasNext) {
|
||
const btn = (action, icon, label) => '<button type="button" class="btn-sm mag-row-action" ' +
|
||
'aria-label="' + esc(label) + ' (region ' + number + ')" title="' + esc(label) + '" ' +
|
||
'onclick="event.preventDefault(); event.stopPropagation(); ' + action + '(\'' + esc(id) + '\')">' +
|
||
'<span class="mdi ' + icon + '" aria-hidden="true"></span></button>';
|
||
return '<span class="mag-row-actions">' +
|
||
btn('magRowEdit', 'mdi-pencil-outline', 'Edit text') +
|
||
btn('magRowReocr', 'mdi-text-recognition', 'Read again') +
|
||
(hasNext ? btn('magRowMerge', 'mdi-table-merge-cells', 'Merge with the next region') : '') +
|
||
'</span>';
|
||
}
|
||
|
||
function magRegionUrl(id) {
|
||
return '/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(id);
|
||
}
|
||
|
||
function magReloadReview(message) {
|
||
// Other actions (opening the text editor) wait for a redraw in flight, or it would wipe them.
|
||
const run = magReloadReviewNow(message).finally(() => { if (state.reloadPending === run) state.reloadPending = null; });
|
||
state.reloadPending = run;
|
||
return run;
|
||
}
|
||
|
||
async function magReloadReviewNow(message) {
|
||
// Keep a keyboard user's place: the row focused when the redraw happens (not when the change
|
||
// started -- the user may have moved on meanwhile) gets focus back.
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
const had = document.activeElement?.closest?.('[data-block-row]')?.dataset.blockRow;
|
||
renderReview();
|
||
if (had) document.querySelector('#mag-review [data-block-row="' + CSS.escape(had) + '"] input[type="checkbox"]')?.focus();
|
||
if (message) say(message, 'ok');
|
||
}
|
||
|
||
async function magRowEdit(id) {
|
||
if (state.reloadPending) await state.reloadPending;
|
||
const block = (state.layout?.blocks || []).find((b) => b.id === id);
|
||
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(id) + '"]');
|
||
const span = row?.querySelector('.mag-block-text');
|
||
if (!block || !span || row.querySelector('.mag-row-editor')) return;
|
||
span.hidden = true;
|
||
const editor = document.createElement('span');
|
||
editor.className = 'mag-row-editor';
|
||
editor.innerHTML = '<textarea aria-label="Text of this region" rows="5"></textarea>' +
|
||
'<span class="mag-row-editor-actions">' +
|
||
'<button type="button" class="btn-sm btn-primary" data-act="save">Save text</button>' +
|
||
'<button type="button" class="btn-sm" data-act="split" title="Split this region into two where the cursor is">Split here</button>' +
|
||
'<button type="button" class="btn-sm" data-act="cancel">Cancel</button></span>';
|
||
span.after(editor);
|
||
const area = editor.querySelector('textarea');
|
||
area.value = block.text;
|
||
['click', 'keydown', 'keyup'].forEach((ev) => editor.addEventListener(ev, (e) => e.stopPropagation()));
|
||
area.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); magReloadReview(); } });
|
||
editor.addEventListener('click', async (e) => {
|
||
const act = e.target.closest('[data-act]')?.dataset.act;
|
||
if (!act) return;
|
||
e.preventDefault();
|
||
try {
|
||
if (act === 'save') {
|
||
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: area.value }) });
|
||
return magReloadReview('Text saved');
|
||
}
|
||
if (act === 'split') {
|
||
const r = await api(magRegionUrl(id) + '/split', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ at: area.selectionStart }) });
|
||
if (r?.new_id) magSelectionAddAlongside(id, r.new_id);
|
||
magOfferUndo(r?.undo, 'Region split in two.');
|
||
return magReloadReview('Region split in two');
|
||
}
|
||
magReloadReview();
|
||
} catch (err) {
|
||
say('Could not change the region: ' + err.message, 'err');
|
||
}
|
||
});
|
||
area.focus();
|
||
}
|
||
|
||
async function magRowReocr(id) {
|
||
say('Reading the region again…', 'info');
|
||
try {
|
||
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reocr: true }) });
|
||
} catch (e) {
|
||
return say('Could not read the region again: ' + e.message, 'err');
|
||
}
|
||
magReloadReview('Region read again');
|
||
}
|
||
|
||
async function magRowMerge(id) {
|
||
try {
|
||
const r = await api(magRegionUrl(id) + '/merge-next', { method: 'POST' });
|
||
if (r?.removed) magRemapSelection(r.removed, null, false);
|
||
magOfferUndo(r?.undo, 'Merged with the next region.');
|
||
} catch (e) {
|
||
return say('Could not merge: ' + e.message, 'err');
|
||
}
|
||
magReloadReview('Merged with the next region');
|
||
}
|
||
|
||
function magListFocus(event) {
|
||
const row = event.target.closest('[data-block-row]');
|
||
if (row) magReviewHighlightBox(row.dataset.blockRow);
|
||
}
|
||
|
||
function magListKey(event) {
|
||
const target = event.target;
|
||
if (target.closest('textarea, select, .mag-row-editor') || (target.tagName === 'INPUT' && target.type === 'number')) return;
|
||
const row = target.closest('[data-block-row]');
|
||
if (!row) return;
|
||
const rows = [...document.querySelectorAll('#mag-review .mag-blocks [data-block-row]')];
|
||
const at = rows.indexOf(row);
|
||
const focusRow = (r) => (r?.querySelector('input[type="checkbox"]') || r)?.focus();
|
||
const id = row.dataset.blockRow;
|
||
const isText = row.tagName === 'LABEL';
|
||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||
if (key === 'ArrowDown' || key === 'j') { event.preventDefault(); focusRow(rows[at + 1]); }
|
||
else if (key === 'ArrowUp' || key === 'k') { event.preventDefault(); focusRow(rows[at - 1]); }
|
||
else if (key === 'Enter' && isText) { event.preventDefault(); magRowEdit(id); }
|
||
else if (key === 'r' && isText) { event.preventDefault(); magRowReocr(id); }
|
||
else if (key === 'm' && isText) { event.preventDefault(); magRowMerge(id); }
|
||
else if (key === 'Delete') { event.preventDefault(); magReviewDeleteRegion(id); }
|
||
else if (key === 'x') { event.preventDefault(); if (!state.selectMode) magSelectToggle(); magSelectToggleOne(id); }
|
||
}
|
||
|
||
// Deleting is immediate and offers Undo -- a confirmation dialog for every box made clearing a page of
|
||
// chart labels a click-marathon.
|
||
async function magReviewDeleteRegion(blockId) {
|
||
let r;
|
||
try {
|
||
r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
|
||
method: 'DELETE',
|
||
});
|
||
magRemapSelection(blockId, null, false);
|
||
state.selected?.delete(blockId);
|
||
} catch (e) {
|
||
return say('Could not delete region: ' + e.message, 'err');
|
||
}
|
||
magOfferUndo(r?.undo, 'Region deleted.');
|
||
await magReloadReview();
|
||
}
|
||
|
||
let _magDrawState = null; // { startXPct, startYPct } while dragging
|
||
|
||
let _magDrawMouseupInstalled = false;
|
||
|
||
function setupMagDrawing() {
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (!wrap) return;
|
||
if (state.drawMode) wrap.classList.add('is-drawing');
|
||
// wrap itself is rebuilt fresh by every renderReview() (host.innerHTML
|
||
// replaces the whole subtree), so listeners on it never accumulate --
|
||
// only the window-level one below needs the one-time guard, since window
|
||
// is never replaced.
|
||
wrap.addEventListener('mousedown', (e) => {
|
||
if (!(state.drawMode || state.selectMode) || e.button !== 0 || e.target.closest('.mag-draw-panel')) return;
|
||
magReviewCancelDrawPanel();
|
||
const rect = wrap.getBoundingClientRect();
|
||
_magDrawState = {
|
||
startXPct: (e.clientX - rect.left) / rect.width * 100,
|
||
startYPct: (e.clientY - rect.top) / rect.height * 100,
|
||
};
|
||
e.preventDefault();
|
||
});
|
||
wrap.addEventListener('mousemove', (e) => {
|
||
if (!_magDrawState) return;
|
||
const rect = wrap.getBoundingClientRect();
|
||
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
|
||
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
|
||
const box = $('mag-draw-rect');
|
||
if (!box) return;
|
||
const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct));
|
||
const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct));
|
||
const w = Math.abs(xPct - _magDrawState.startXPct);
|
||
const h = Math.abs(yPct - _magDrawState.startYPct);
|
||
box.hidden = false;
|
||
box.style.left = left + '%'; box.style.top = top + '%';
|
||
box.style.width = w + '%'; box.style.height = h + '%';
|
||
});
|
||
if (!_magDrawMouseupInstalled) {
|
||
_magDrawMouseupInstalled = true;
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && state.drawMode) magReviewToggleDraw(state.drawKind);
|
||
if (e.key === 'Escape' && state.editMode) magReviewToggleEdit();
|
||
});
|
||
// $('mag-draw-rect') is looked up fresh on every mouseup, so this one
|
||
// listener keeps working correctly across every renderReview() rebuild
|
||
// without needing to be re-attached.
|
||
window.addEventListener('mouseup', (e) => {
|
||
if (!_magDrawState) return;
|
||
const wrap = $('mag-review-page-wrap');
|
||
const box = $('mag-draw-rect');
|
||
if (wrap && box) {
|
||
const rect = wrap.getBoundingClientRect();
|
||
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
|
||
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
|
||
const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct));
|
||
const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct));
|
||
const w = Math.abs(xPct - _magDrawState.startXPct);
|
||
const h = Math.abs(yPct - _magDrawState.startYPct);
|
||
box.hidden = false;
|
||
box.style.left = left + '%'; box.style.top = top + '%';
|
||
box.style.width = w + '%'; box.style.height = h + '%';
|
||
}
|
||
_magDrawState = null;
|
||
if (!box || box.hidden) return;
|
||
const w = parseFloat(box.style.width) || 0, h = parseFloat(box.style.height) || 0;
|
||
if (w < 1 || h < 1) { box.hidden = true; return; } // too small to be intentional
|
||
if (state.selectMode) { box.hidden = true; return magSelectInRect(parseFloat(box.style.left), parseFloat(box.style.top), w, h); }
|
||
magReviewOpenDrawPanel(box);
|
||
});
|
||
}
|
||
}
|
||
|
||
function magReviewCancelDrawPanel() {
|
||
_magDrawState = null;
|
||
const box = $('mag-draw-rect');
|
||
if (box) box.hidden = true;
|
||
const panel = document.querySelector('.mag-draw-panel');
|
||
if (panel) panel.remove();
|
||
}
|
||
|
||
function magReviewOpenDrawPanel(box) {
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (!wrap) return;
|
||
box.hidden = false;
|
||
const panel = document.createElement('div');
|
||
panel.className = 'mag-draw-panel';
|
||
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
|
||
// One generic draw tool now covers every region kind (previously six
|
||
// separate "Draw X" buttons pre-selected the kind before drawing) — the
|
||
// kind is picked here, after the rectangle is already on the page, since
|
||
// that's the order a person actually works in: see the shape, then say
|
||
// what it is.
|
||
const drawKindOptions = MAG_REGION_TYPES;
|
||
panel.innerHTML = field
|
||
? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' +
|
||
'<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' +
|
||
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
|
||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>'
|
||
:
|
||
'<div class="mag-draw-panel-row">' +
|
||
'<div class="mag-type-picker" id="mag-draw-kind-picker">' +
|
||
'<label for="mag-draw-kind-search">Region type</label>' +
|
||
'<input type="text" id="mag-draw-kind-search" class="mag-type-picker-input" autocomplete="off" ' +
|
||
'placeholder="Search region type…" aria-expanded="false" role="combobox" aria-autocomplete="list">' +
|
||
'<input type="hidden" id="mag-draw-kind" value="body">' +
|
||
'<ul class="mag-type-picker-list" id="mag-draw-kind-list" role="listbox" hidden></ul>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<label id="mag-draw-exclude" hidden><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
|
||
'<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
|
||
'<div class="mag-draw-panel-row">' +
|
||
'<button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
|
||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
|
||
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
|
||
'</div>';
|
||
wrap.appendChild(panel);
|
||
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
|
||
panel.style.top = Math.min(85, top + height + 1) + '%';
|
||
panel.style.left = Math.min(60, left) + '%';
|
||
if ($('mag-draw-kind-picker')) _magSetupDrawKindPicker(drawKindOptions);
|
||
}
|
||
|
||
// A 16-option <select> for something picked constantly deserved faster
|
||
// access than scrolling a native dropdown -- type to filter, arrow keys or
|
||
// click to choose, icon per type so the list scans visually instead of by
|
||
// re-reading every label. #mag-draw-kind stays a plain hidden input so
|
||
// magReviewSubmitDraw's existing $('mag-draw-kind').value read needed no
|
||
// changes.
|
||
function _magDrawKindOnPick(value) {
|
||
const isImage = value === 'image';
|
||
const ta = $('mag-draw-text');
|
||
if (ta) ta.hidden = isImage;
|
||
const exclude = $('mag-draw-exclude');
|
||
if (exclude) exclude.hidden = !isImage;
|
||
}
|
||
|
||
function _magSetupDrawKindPicker(options) {
|
||
const search = $('mag-draw-kind-search');
|
||
const hidden = $('mag-draw-kind');
|
||
const list = $('mag-draw-kind-list');
|
||
if (!search || !hidden || !list) return;
|
||
|
||
const renderList = (query) => {
|
||
const q = (query || '').trim().toLowerCase();
|
||
const matches = q ? options.filter(([, label]) => label.toLowerCase().includes(q)) : options;
|
||
list.innerHTML = matches.length
|
||
? matches.map(([v, label]) => '<li role="option" data-value="' + v + '" ' +
|
||
'class="mag-type-picker-option' + (v === hidden.value ? ' is-selected' : '') + '">' +
|
||
'<span class="mdi ' + (MAG_REGION_TYPE_ICONS[v] || 'mdi-text-box-outline') + '" aria-hidden="true"></span> ' +
|
||
esc(label) + '</li>').join('')
|
||
: '<li class="mag-type-picker-empty muted">No matching region type</li>';
|
||
};
|
||
|
||
const pick = (value, label) => {
|
||
hidden.value = value;
|
||
search.value = label;
|
||
list.hidden = true;
|
||
search.setAttribute('aria-expanded', 'false');
|
||
_magDrawKindOnPick(value);
|
||
};
|
||
|
||
const open = () => {
|
||
renderList(search.value === (options.find(([v]) => v === hidden.value) || [])[1] ? '' : search.value);
|
||
list.hidden = false;
|
||
search.setAttribute('aria-expanded', 'true');
|
||
};
|
||
|
||
// Start on the default (Paragraph) with its label showing, matching the
|
||
// hidden input's own default value set in the HTML above.
|
||
pick(hidden.value, (options.find(([v]) => v === hidden.value) || [])[1] || '');
|
||
search.addEventListener('focus', open);
|
||
search.addEventListener('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); });
|
||
search.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); }
|
||
else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const first = list.querySelector('.mag-type-picker-option');
|
||
if (first) { const v = first.dataset.value; pick(v, options.find(([ov]) => ov === v)[1]); }
|
||
} else if (e.key === 'ArrowDown' && list.hidden) { open(); }
|
||
});
|
||
list.addEventListener('mousedown', (e) => {
|
||
// mousedown (not click) fires before the input's blur, so the list is
|
||
// still in the DOM to read from when the handler below reacts to blur.
|
||
const li = e.target.closest('.mag-type-picker-option');
|
||
if (!li) return;
|
||
e.preventDefault();
|
||
pick(li.dataset.value, options.find(([v]) => v === li.dataset.value)[1]);
|
||
});
|
||
search.addEventListener('blur', () => {
|
||
setTimeout(() => { list.hidden = true; search.setAttribute('aria-expanded', 'false'); }, 0);
|
||
});
|
||
}
|
||
|
||
async function magReviewSubmitDraw() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const box = $('mag-draw-rect');
|
||
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
|
||
if (!box || !pageMeta) return;
|
||
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
|
||
const kind = field ? 'text' : ($('mag-draw-kind')?.value || 'text');
|
||
const text = ($('mag-draw-text')?.value || '').trim();
|
||
const bbox = [
|
||
parseFloat(box.style.left) / 100 * pageMeta.width,
|
||
parseFloat(box.style.top) / 100 * pageMeta.height,
|
||
parseFloat(box.style.width) / 100 * pageMeta.width,
|
||
parseFloat(box.style.height) / 100 * pageMeta.height,
|
||
];
|
||
const excludeText = kind === 'image' && $('mag-draw-exclude-check')?.checked;
|
||
const article = state.article;
|
||
const page = state.reviewPage;
|
||
const oldIds = new Set((kind === 'image' ? state.layout.images || [] : state.layout.blocks || []).map(r => r.id));
|
||
article.title = $('mag-rev-title').value;
|
||
article.author = $('mag-rev-author').value;
|
||
if (!_magReviewChecked) _magReviewChecked = new Set(article.blocks || []);
|
||
magReviewCancelDrawPanel();
|
||
if (field) {
|
||
state.drawMode = false;
|
||
_magSyncDrawUi();
|
||
say(`Reading selected ${field}…`, '');
|
||
try {
|
||
const result = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/extract', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox }),
|
||
});
|
||
const value = String(result.text || '').replace(/\s+/g, ' ').trim();
|
||
if (!value) return say('No text found in that selection', 'err');
|
||
article[field] = value;
|
||
const input = $(field === 'title' ? 'mag-rev-title' : 'mag-rev-author');
|
||
if (input) input.value = value;
|
||
say(`${field === 'title' ? 'Title' : 'Author'} filled from selected text`, 'ok');
|
||
} catch (e) { say(`Could not read selected ${field}: ` + e.message, 'err'); }
|
||
return;
|
||
}
|
||
say('Adding region…', '');
|
||
let jobId;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
// Every text-like kind gets read automatically if left blank — except
|
||
// an advertisement, which is a graphic, not something worth OCRing.
|
||
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && kind !== 'ad' && !text }),
|
||
});
|
||
jobId = r.job_id;
|
||
} catch (e) {
|
||
return say('Could not add region: ' + e.message, 'err');
|
||
}
|
||
for (;;) {
|
||
let job;
|
||
try { job = await api('/api/jobs/' + jobId); } catch (e) { return say('Could not check region: ' + e.message, 'err'); }
|
||
if (job.done) {
|
||
if (job.error) return say('Could not add region: ' + job.error, 'err');
|
||
else say('Region added', 'ok');
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
}
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
if (state.article !== article) return;
|
||
const added = (kind === 'image' ? state.layout.images || [] : state.layout.blocks || [])
|
||
.filter(r => r.page === page && !oldIds.has(r.id));
|
||
if (kind === 'image') {
|
||
article.images = [...new Set((article.images || []).concat(added.map(r => r.id)))];
|
||
if (excludeText) {
|
||
for (const b of state.layout.blocks || []) {
|
||
const [x, y, w, h] = b.bbox;
|
||
if (b.page === page && x >= bbox[0] && y >= bbox[1] &&
|
||
x + w <= bbox[0] + bbox[2] && y + h <= bbox[1] + bbox[3]) _magReviewChecked.delete(b.id);
|
||
}
|
||
}
|
||
} else if (MAG_NARRATABLE_KINDS.has(kind)) {
|
||
// Drawing a region at all means the reader judged it part of the
|
||
// article -- it should read as included, not force a second click per
|
||
// box to tick what was just drawn. Furniture-like kinds (page number,
|
||
// source, advertisement, table, duplicate, OCR noise) stay unchecked,
|
||
// since those are excluded from narration on purpose.
|
||
added.forEach(r => _magReviewChecked.add(r.id));
|
||
}
|
||
renderReview();
|
||
// Draw region is a toggle, not a one-shot: stay armed for the next box
|
||
// instead of forcing another click on the toolbar button every time.
|
||
// renderReview() just rebuilt the toolbar from scratch with no memory of
|
||
// drawMode, so the "still drawing" visuals need reapplying here.
|
||
if (state.drawMode) _magSyncDrawUi();
|
||
}
|
||
|
||
// Keep the review's unsaved selection in step with a region change made on the server. Without this,
|
||
// Approve sent ids that no longer existed ("Unknown block ids") after a delete, merge or type change,
|
||
// and sent a stale image list that dropped a text box just turned into an image.
|
||
function magRemapSelection(oldId, newId, newIsImage) {
|
||
const a = state.article;
|
||
if (!a) return;
|
||
if (!_magReviewChecked) _magReviewChecked = new Set(a.blocks || []);
|
||
const wasOn = _magReviewChecked.has(oldId) || (a.images || []).includes(oldId);
|
||
_magReviewChecked.delete(oldId);
|
||
a.blocks = (a.blocks || []).filter((id) => id !== oldId);
|
||
a.images = (a.images || []).filter((id) => id !== oldId);
|
||
if (!newId || !wasOn) return;
|
||
if (newIsImage) a.images = a.images.concat([newId]);
|
||
else { _magReviewChecked.add(newId); a.blocks = a.blocks.concat([newId]); }
|
||
}
|
||
|
||
// A region split in two: the new part is included wherever the original was.
|
||
function magSelectionAddAlongside(existingId, newId) {
|
||
const a = state.article;
|
||
if (!a) return;
|
||
if (!_magReviewChecked) _magReviewChecked = new Set(a.blocks || []);
|
||
if (_magReviewChecked.has(existingId)) { _magReviewChecked.add(newId); a.blocks = (a.blocks || []).concat([newId]); }
|
||
}
|
||
|
||
function selectedBlockIds() {
|
||
const exists = new Set((state.layout?.blocks || []).map((b) => b.id));
|
||
const ids = _magReviewChecked ? Array.from(_magReviewChecked)
|
||
: Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')).map((el) => el.dataset.block);
|
||
return state.layout ? ids.filter((id) => exists.has(id)) : ids;
|
||
}
|
||
|
||
async function magReocrPage() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const page = state.reviewPage;
|
||
if (page == null) return;
|
||
const btn = $('mag-reocr-btn');
|
||
const statusEl = $('mag-reocr-status');
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.textContent = 'Reading page ' + page + '…';
|
||
let jobId;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/reocr', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||
});
|
||
jobId = r.job_id;
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = '';
|
||
if (btn) btn.disabled = false;
|
||
return say('Could not start OCR: ' + e.message, 'err');
|
||
}
|
||
for (;;) {
|
||
let job;
|
||
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
|
||
if (job.done) {
|
||
if (job.error) {
|
||
if (btn) btn.disabled = false;
|
||
if (statusEl) statusEl.textContent = '';
|
||
return say('OCR failed: ' + job.error, 'err');
|
||
}
|
||
else say('Page ' + page + ' re-read', 'ok');
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
}
|
||
if (btn) btn.disabled = false;
|
||
if (statusEl) statusEl.textContent = '';
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
const articleId = state.article.id;
|
||
await magRefreshArticles();
|
||
state.article = state.articles.find(a => a.id === articleId) || state.article;
|
||
_magReviewChecked = null;
|
||
renderReview();
|
||
}
|
||
|
||
// Re-derives every figure box in the issue from the page renders. Separate
|
||
// from "OCR again" because no OCR engine is involved: it only corrects where
|
||
// the figures are, never what the page says, so it is safe to run on an issue
|
||
// that is half reviewed already.
|
||
async function magRefineFigures() {
|
||
const btn = $('mag-refine-figures-btn');
|
||
const statusEl = $('mag-reocr-status');
|
||
if (btn) btn.disabled = true;
|
||
if (statusEl) statusEl.textContent = 'Checking figure boundaries…';
|
||
let jobId;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/figures/refine', { method: 'POST' });
|
||
jobId = r.job_id;
|
||
} catch (e) {
|
||
if (btn) btn.disabled = false;
|
||
if (statusEl) statusEl.textContent = '';
|
||
return say('Could not start the check: ' + e.message, 'err');
|
||
}
|
||
let failed = false;
|
||
for (;;) {
|
||
let job;
|
||
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
|
||
if (job.done) {
|
||
if (job.error) { failed = true; say('Figure check failed: ' + job.error, 'err'); }
|
||
else say(job.detail || 'Figure boundaries checked', 'ok');
|
||
break;
|
||
}
|
||
if (statusEl && job.detail) statusEl.textContent = job.detail;
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
}
|
||
if (btn) btn.disabled = false;
|
||
if (statusEl) statusEl.textContent = '';
|
||
if (failed) return;
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
_magReviewChecked = null;
|
||
renderReview();
|
||
}
|
||
|
||
async function magApprove() {
|
||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||
const a = state.article;
|
||
if (!a) return;
|
||
const blocks = selectedBlockIds();
|
||
if (!blocks.length) return say('Tick at least one block before approving', 'err');
|
||
try {
|
||
const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
blocks,
|
||
images: (a.images || []).filter((id) => !state.layout || (state.layout.images || []).some((img) => img.id === id)),
|
||
status: 'approved',
|
||
title: ($('mag-rev-title')?.value || a.title).trim(),
|
||
author: ($('mag-rev-author')?.value || a.author).trim(),
|
||
}),
|
||
});
|
||
Object.assign(a, updated);
|
||
say('Approved — ready to listen', 'ok');
|
||
// Approving one article at a time is a queue, not a one-off — staying on
|
||
// the list meant re-finding and re-opening whichever article came next
|
||
// after every single approval. Go straight to it instead, and only fall
|
||
// back to the list once nothing is left to review.
|
||
const idx = state.articles.findIndex((x) => x.id === a.id);
|
||
const nextId = idx >= 0 ? state.articles[idx + 1]?.id : null;
|
||
await magRefreshArticles();
|
||
if (nextId && state.articles.some((x) => x.id === nextId)) magReview(nextId);
|
||
else magShow('contents');
|
||
} catch (e) {
|
||
say('Could not approve: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
async function magSetStatus(status) {
|
||
const a = state.article;
|
||
if (!a) return;
|
||
try {
|
||
const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
Object.assign(a, updated);
|
||
await magRefreshArticles();
|
||
magShow('contents');
|
||
} catch (e) {
|
||
say('Could not update: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// ── Reading: sentences, both views ───────────────────────────────────────────
|
||
|
||
// Sentence splitting keeps each piece tied to its source block, so highlighting
|
||
// in the reflowed view can drive the page shown in the PDF view.
|
||
function splitSentences(blocks) {
|
||
const out = [];
|
||
blocks.forEach((b) => {
|
||
const parts = String(b.text || '')
|
||
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
|
||
.map((s) => s.trim()).filter(Boolean);
|
||
(parts.length ? parts : [b.text]).forEach((text) => {
|
||
if (text && text.trim()) {
|
||
out.push({
|
||
text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type,
|
||
// Used to pick "the image reached so far" during playback (see
|
||
// currentImageIndex) -- the backend already computes this per
|
||
// block relative to the whole page, not just this article.
|
||
readingOrder: b.reading_order || 0,
|
||
words: text.trim().split(/\s+/).filter(Boolean),
|
||
});
|
||
}
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// The current article's figures, in reading order, resolved from the full
|
||
// layout (fetched once) filtered to this article's own image ids. Kept
|
||
// separate from state.sentences because an image has no sentence of its
|
||
// own -- it is matched to "the most recent sentence at or before it".
|
||
function articleImages(article) {
|
||
const ids = new Set(article.images || []);
|
||
return (state.layout.images || [])
|
||
.filter((img) => ids.has(img.id))
|
||
.sort((a, b) => (a.reading_order || 0) - (b.reading_order || 0));
|
||
}
|
||
|
||
// The image to show once sentence `i` has been reached: the last image
|
||
// whose position in the page's reading order is at or before that
|
||
// sentence's own block. Falls back to the first image before any text has
|
||
// caught up to it, so a lead photo is not left blank at the very start.
|
||
function currentImageIndex(i) {
|
||
if (!state.images.length) return -1;
|
||
const order = (state.sentences[i] || {}).readingOrder || 0;
|
||
let found = -1;
|
||
for (let k = 0; k < state.images.length; k++) {
|
||
if ((state.images[k].reading_order || 0) <= order) found = k;
|
||
else break;
|
||
}
|
||
return found === -1 ? 0 : found;
|
||
}
|
||
|
||
function renderCurrentImage(i) {
|
||
const host = $('mag-read-image');
|
||
if (!host) return;
|
||
const idx = currentImageIndex(i);
|
||
if (idx < 0) { host.hidden = true; return; }
|
||
const img = state.images[idx];
|
||
if (state._shownImageId === img.id) { host.hidden = false; return; }
|
||
state._shownImageId = img.id;
|
||
host.hidden = false;
|
||
host.innerHTML = '<img src="/api/magazine/' + state.doc.id + '/images/' + encodeURIComponent(img.filename) +
|
||
'" alt="Figure from page ' + img.page + '" loading="lazy">';
|
||
}
|
||
|
||
async function magRead(articleId, opts) {
|
||
const a = state.articles.find((x) => x.id === articleId);
|
||
if (!a) return;
|
||
// A generated MP3 already exists: play that directly through the
|
||
// playlist player instead of picking a narrator and synthesising sentence
|
||
// by sentence again -- confirmed live, "Listen" on an article that had
|
||
// already been exported still went through the live reader from scratch.
|
||
if (a.has_audio) {
|
||
magTogglePlaylist(true);
|
||
magPlaylistPlay(articleId);
|
||
const saved = !(opts && opts.fromStart) && (state.doc.progress || {})[articleId];
|
||
if (saved && saved.seconds) {
|
||
const audio = $('mag-playlist-audio');
|
||
if (audio) {
|
||
const resume = () => { audio.currentTime = saved.seconds; audio.removeEventListener('loadedmetadata', resume); };
|
||
audio.addEventListener('loadedmetadata', resume);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
state.article = a;
|
||
state.audioCache = new Map();
|
||
state.prepGen += 1;
|
||
state.preparing = false;
|
||
state.prepCancel = false;
|
||
syncPrepButton();
|
||
await ensureLayout();
|
||
try {
|
||
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
|
||
($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' +
|
||
($('mag-read-descriptions')?.checked ? 1 : 0));
|
||
state.sentences = splitSentences(data.blocks || []);
|
||
} catch (e) {
|
||
return say('Could not load article text: ' + e.message, 'err');
|
||
}
|
||
state.images = articleImages(a);
|
||
state._shownImageId = null;
|
||
const forceStart = opts && opts.fromStart;
|
||
const saved = !forceStart && (state.doc.progress || {})[a.id];
|
||
state.idx = saved && saved.sentence < state.sentences.length ? saved.sentence : 0;
|
||
magShow('read');
|
||
renderArticle();
|
||
loadPodcastIfAny();
|
||
}
|
||
|
||
function renderArticle() {
|
||
const host = $('mag-article');
|
||
const a = state.article;
|
||
if (!host || !a) return;
|
||
$('mag-read-title').textContent = a.title || 'Untitled';
|
||
$('mag-read-by').textContent = a.author ? 'By ' + a.author : '';
|
||
$('mag-read-pages').textContent = fmtPages(a.pages);
|
||
|
||
host.innerHTML = state.sentences.map((s, i) => {
|
||
const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"'
|
||
: s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption'
|
||
? 'p class="mag-caption"' : s.type === 'figure_description'
|
||
? 'p class="mag-figure-description"' : 'span';
|
||
const open = tag.split(' ')[0];
|
||
// Each word gets its own span so playback can highlight it individually
|
||
// (see estimateWordTimings) -- sentence-level highlighting alone doesn't
|
||
// need this, but word-level does and this markup serves both.
|
||
const wordsHtml = s.words.map((w, j) =>
|
||
'<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>').join(' ');
|
||
return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' +
|
||
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ';
|
||
}).join('');
|
||
highlight(state.idx);
|
||
}
|
||
|
||
function highlight(i) {
|
||
document.querySelectorAll('#mag-article [data-i]').forEach((el) => {
|
||
el.classList.toggle('is-current', Number(el.dataset.i) === i);
|
||
});
|
||
clearWordHighlight();
|
||
const el = document.querySelector('#mag-article [data-i="' + i + '"]');
|
||
if (el && !prefersReducedMotion()) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
else if (el) el.scrollIntoView({ block: 'center' });
|
||
const s = state.sentences[i];
|
||
if (s && state.pane === 'pdf') showPage(s.page, s.bbox);
|
||
renderCurrentImage(i);
|
||
const counter = $('mag-read-progress');
|
||
if (counter) counter.textContent = (i + 1) + ' / ' + state.sentences.length;
|
||
const qText = state.queue.length ? 'Article ' + (state.queueIndex + 1) + ' / ' + state.queue.length : '';
|
||
['mag-queue-progress', 'mag-queue-progress-2'].forEach((id) => {
|
||
const el = $(id);
|
||
if (el) el.textContent = qText;
|
||
});
|
||
}
|
||
|
||
function clearWordHighlight() {
|
||
document.querySelectorAll('#mag-article .mag-w.is-current').forEach((el) => el.classList.remove('is-current'));
|
||
}
|
||
|
||
// ── Word-level highlighting (approximate) ────────────────────────────────
|
||
// No forced-alignment or TTS timestamp source exists in this pipeline, so
|
||
// per-word timing is ESTIMATED, not measured: each word's share of the
|
||
// sentence's real (measured) audio duration is proportional to its own
|
||
// character length, with a small fixed share added per word so short words
|
||
// are not given an unrealistically instant duration. This is a common
|
||
// karaoke approximation, not true alignment -- it can drift on sentences
|
||
// with heavy punctuation-driven pauses.
|
||
function estimateWordTimings(words, totalSeconds) {
|
||
if (!words.length || !totalSeconds) return [];
|
||
const FIXED = 0.6; // arbitrary units, shifts weight toward evening out short words
|
||
const weights = words.map((w) => Math.max(1, w.replace(/[.,!?;:"'()«»„"]/g, '').length) + FIXED);
|
||
const total = weights.reduce((a, b) => a + b, 0);
|
||
let t = 0;
|
||
return weights.map((w) => {
|
||
const dur = totalSeconds * (w / total);
|
||
const start = t;
|
||
t += dur;
|
||
return { start, end: t };
|
||
});
|
||
}
|
||
|
||
function highlightWord(sentenceIdx, wordIdx) {
|
||
const sentenceEl = document.querySelector('#mag-article [data-i="' + sentenceIdx + '"]');
|
||
if (!sentenceEl) return;
|
||
sentenceEl.querySelectorAll('.mag-w').forEach((el) => {
|
||
el.classList.toggle('is-current', Number(el.dataset.w) === wordIdx);
|
||
});
|
||
}
|
||
|
||
function prefersReducedMotion() {
|
||
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
|
||
catch (_) { return false; }
|
||
}
|
||
|
||
function magSeek(i) {
|
||
state.idx = Math.max(0, Math.min(state.sentences.length - 1, i));
|
||
highlight(state.idx);
|
||
saveProgress();
|
||
}
|
||
|
||
function magSetPane(pane) {
|
||
state.pane = pane;
|
||
const reflow = $('mag-pane-reflow'), pdf = $('mag-pane-pdf');
|
||
if (reflow) reflow.hidden = pane !== 'reflow';
|
||
if (pdf) pdf.hidden = pane !== 'pdf';
|
||
document.querySelectorAll('[data-mag-pane]').forEach((b) => {
|
||
const on = b.dataset.magPane === pane;
|
||
b.classList.toggle('active', on);
|
||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
});
|
||
if (pane === 'pdf') {
|
||
const s = state.sentences[state.idx];
|
||
if (s) showPage(s.page, s.bbox);
|
||
}
|
||
}
|
||
|
||
function showPage(page, bbox) {
|
||
const img = $('mag-page-img');
|
||
const box = $('mag-page-box');
|
||
if (!img) return;
|
||
const src = '/api/magazine/' + state.doc.id + '/pages/' + page;
|
||
if (img.getAttribute('src') !== src) img.setAttribute('src', src);
|
||
img.setAttribute('alt', 'Page ' + page + ' of ' + (state.doc.title || 'the magazine'));
|
||
const label = $('mag-page-label');
|
||
if (label) label.textContent = 'Page ' + page;
|
||
if (!box || !bbox) return;
|
||
// The layout stores PDF user-space units; the rendered PNG is a different
|
||
// size, so scale by the page dimensions rather than assuming they match.
|
||
const meta = (state.layout.pages || []).find((p) => p.page === page);
|
||
if (!meta) { box.hidden = true; return; }
|
||
box.hidden = false;
|
||
box.style.left = (bbox[0] / meta.width * 100) + '%';
|
||
box.style.top = (bbox[1] / meta.height * 100) + '%';
|
||
box.style.width = (bbox[2] / meta.width * 100) + '%';
|
||
box.style.height = (bbox[3] / meta.height * 100) + '%';
|
||
}
|
||
|
||
function magPeekPage(page) {
|
||
magShow('read');
|
||
magSetPane('pdf');
|
||
showPage(page, null);
|
||
}
|
||
|
||
// ── Audio preparation ────────────────────────────────────────────────────────
|
||
// Synthesised sentences are cached as promises keyed by voice + text, so a
|
||
// prefetch and playback asking for the same sentence share one request, and a
|
||
// changed voice or edited text can never be served stale audio. Held in memory
|
||
// only: closing the tab discards it.
|
||
const MAG_PREP_CONCURRENCY = 2;
|
||
const MAG_LOOKAHEAD = 3;
|
||
|
||
function magNarratorVoice() {
|
||
return ($('mag-voice')?.value || state.doc.default_voice || '').trim();
|
||
}
|
||
|
||
function magSynth(voice, text) {
|
||
const key = voice + ' |