diff --git a/CHANGELOG.md b/CHANGELOG.md index 89feb56..6978238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.31.0] — 2026-09-16 + +### Fixed +- **"Edit boxes" (added in 1.30.0) silently did nothing — clicking it, or any of its move/resize/delete/save controls, was a dead click.** `magazine.js` wraps the whole module in an IIFE and only exposes functions the inline `onclick=` HTML attributes call by explicitly assigning them onto `root` at the bottom of the file; `magReviewToggleEdit`, `magReviewCancelEditPanel`, `magReviewSubmitEdit`, `magReviewDeleteRegion` and `magReviewClearCurrentPage` were never added to that list, so every one of those clicks threw an unhandled `ReferenceError` in the browser console with no visible feedback in the UI. Added the five missing `root.magReview* = ...` lines. +- **"Edit boxes" could only resize a region from its bottom-right corner**, so two regions couldn't be split correctly along a shared boundary — e.g. a paragraph OCR'd as one box that actually belongs to two, where the fix is dragging *just* the line between them. Replaced the single corner handle with one handle per edge and per corner (`static/js/magazine.js`, `static/style.css`): dragging a side handle (top/bottom/left/right) now moves only that edge, leaving the opposite edge and both other dimensions untouched, so a shared boundary can be corrected on each box independently and then re-OCR'd. +- The "Previous page"/"Next page" (article-scoped) buttons in the page navigator called `magReviewStepPage`, a function that did not exist anywhere in the file (also never exported) — they were also permanently disabled by a `state.reviewPageIdx` that was read but never set anywhere. Implemented `magReviewStepPage`, exported it, and derive the page index from `state.article.pages` at render time instead of the dead field. + +### Added +- **Prepare audio in the Magazine reader.** A new button synthesises the whole article ahead of time (two requests in flight, current position first, cancellable, with "Preparing audio n / N" progress) so Listen then plays straight through without waiting sentence by sentence. Even without it, playback now stays three sentences ahead of the current one. Synthesised sentences are cached in memory keyed by voice + text (`magSynth`), so a changed narrator or edited text can never replay stale audio, a failed request is never cached, and switching article discards the cache. Not persisted: a reload loses it. +- **Searchable voice picker for the Narrator and podcast Host A/B/C selects** in the Magazine reader, replacing the plain dropdown with the app-wide picker (`voice-picker.js`). The `' + + ' ' + esc(r.data.type || 'image') + '' + + '
' + + '' + esc(r.data.type || 'figure') + '' + + '' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title) + : on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '' + + '
' + + magDescribeMarkup(r.id, r.data.description) + + ''; + return; + } + const b = r.data; + const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type); + const on = included.has(b.id); + listHtml += ''; + }); + if (!regions.length) listHtml += '

Nothing detected on this page.

'; + listHtml += ''; + + 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 magReviewStepPage(dir) { + const pages = state.article?.pages || []; + const next = pages[pages.indexOf(state.reviewPage) + dir]; + if (next == null) return; + state.reviewPage = next; + renderReview(); +} + +function renderReview() { + const host = $('mag-review'); + const a = state.article; + if (!host || !a) return; + const conf = confidenceLabel(a.confidence); + const page = state.reviewPage; + const pageCount = Number(state.layout?.page_count || 0); + const pageIdx = (a?.pages || []).indexOf(page); + 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 += '
' + '
' + - '' + 'Page ' + (page ?? '—') + '' + - '
' + '
' + '
' + + '
' + + '
' + '
' + '' + @@ -774,84 +906,35 @@ function renderReview() { html += '

Set a language model in Settings to recognise advertisements automatically.

'; } - html += '
'; - - html += '
'; - if (page != null) { - html += '
' + - 'Page ' + page + '' + - ''; - 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 icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type); - html += '
' + - '' + (i + 1) + '' + - '' + - (owner ? 'Art. ' + owner.index + '' : '') + - '' + - '' + - '
'; + 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 += '
'; + cols.forEach((c) => { + html += '
' + + '
Page ' + c.page + '' + + (c.interactive ? ' Editing' + : ' ') + + '
' + c.pageHtml + '
'; + }); + cols.forEach((c) => { + html += '

OCR results — page ' + c.page + '

' + c.listHtml + '
'; }); html += '
'; } else { - html += '

No page to show.

'; + const col = magReviewPageColumn(page, 'mag-review-page-wrap', true); + html += '
' + + '
' + col.pageHtml + '
' + + col.listHtml + + '
'; } - html += '
'; - - html += '
'; - 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'); - const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename); - html += '
' + - '' + (i + 1) + '' + - '' + - ' ' + esc(r.data.type || 'image') + '' + - '
' + - '' + esc(r.data.type || 'figure') + '' + - '' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title) - : on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '' + - '
' + - magDescribeMarkup(r.id, r.data.description) + - '
'; - return; - } - const b = r.data; - const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type); - const on = included.has(b.id); - html += ''; - }); - if (!regions.length) html += '

Nothing detected on this page.

'; - html += '
'; - - html += '
'; // /mag-review-split html += '
' + '' + @@ -1034,7 +1117,13 @@ function magReviewToggleEdit() { magReviewCancelEditPanel(); } -let _magEditState = null; // { blockId, mode: 'move'|'resize', box, startXPct, startYPct, origLeft, origTop, origW, origH } +// 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) => + '').join(''); + +let _magEditState = null; // { blockId, mode: 'move'|'resize', dir, box, startXPct, startYPct, origLeft, origTop, origW, origH } function setupMagBoxEdit() { const wrap = $('mag-review-page-wrap'); @@ -1054,6 +1143,7 @@ function setupMagBoxEdit() { _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, @@ -1064,6 +1154,7 @@ function setupMagBoxEdit() { }; e.preventDefault(); }); + const MAG_RESIZE_MIN_PCT = 1; wrap.addEventListener('mousemove', (e) => { if (!_magEditState) return; const rect = wrap.getBoundingClientRect(); @@ -1071,14 +1162,29 @@ function setupMagBoxEdit() { 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 } = _magEditState; + 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 { - const w = Math.max(1, Math.min(100 - origLeft, origW + dx)); - const h = Math.max(1, Math.min(100 - origTop, origH + dy)); + // 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 + '%'; } }); @@ -1590,6 +1696,11 @@ async function magRead(articleId, opts) { const a = state.articles.find((x) => x.id === articleId); if (!a) 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=' + @@ -1742,11 +1853,83 @@ function magPeekPage(page) { 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 + '' + text; + let p = state.audioCache.get(key); + if (!p) { + p = fetchTtsPreviewBlob(voice, text, 'mp3'); + const cache = state.audioCache; + cache.set(key, p); + // A failed request must not be cached, or a retry would replay the failure. + p.catch(() => { if (cache.get(key) === p) cache.delete(key); }); + } + return p; +} + +function syncPrepButton(done, total) { + const btn = $('mag-prepare'); + if (btn) { + btn.innerHTML = state.preparing + ? ' Cancel' + : ' Prepare audio'; + } + const status = $('mag-prep-status'); + if (status) status.textContent = state.preparing && total ? 'Preparing audio ' + done + ' / ' + total : ''; +} + +async function magPrepareAudio() { + if (state.preparing) { state.prepCancel = true; return; } + const voice = magNarratorVoice(); + if (!voice) return say('Pick a narrator voice first', 'err'); + const total = state.sentences.length; + if (!total) return; + const gen = state.prepGen; + state.preparing = true; + state.prepCancel = false; + syncPrepButton(0, total); + // Start at the current position so Listen is ready soonest, then wrap round + // to the part before it. + const order = Array.from({ length: total }, (_, k) => (state.idx + k) % total); + let next = 0, done = 0, failure = null; + const worker = async () => { + while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) { + const i = order[next++]; + try { await magSynth(voice, state.sentences[i].text); } + catch (e) { failure = { i, message: e.message }; return; } + done += 1; + if (gen === state.prepGen) syncPrepButton(done, total); + } + }; + await Promise.all(Array.from({ length: Math.min(MAG_PREP_CONCURRENCY, total) }, worker)); + // An article change while this ran already reset the flags -- touching them + // now would clobber a newer run. + if (gen !== state.prepGen) return; + const cancelled = state.prepCancel; + state.preparing = false; + state.prepCancel = false; + syncPrepButton(); + if (failure) say('Synthesis failed at sentence ' + (failure.i + 1) + ': ' + failure.message, 'err'); + else if (cancelled) say('Audio preparation cancelled', ''); + else say('Audio ready — ' + total + ' sentences', 'ok'); +} + // ── Playback ───────────────────────────────────────────────────────────────── async function magPlay() { if (state.playing) return magPause(); - const voice = ($('mag-voice')?.value || state.doc.default_voice || '').trim(); + const voice = magNarratorVoice(); if (!voice) return say('Pick a narrator voice first', 'err'); state.playing = true; state.cancel = false; @@ -1758,7 +1941,16 @@ async function magPlay() { const sentence = state.sentences[state.idx]; let blob; try { - blob = await fetchTtsPreviewBlob(voice, sentence.text, 'mp3'); + const pending = magSynth(voice, sentence.text); + // Without a full "Prepare audio" run, still stay a few sentences ahead + // so the wait is only ever for the first one. + if (!state.preparing) { + for (let k = 1; k <= MAG_LOOKAHEAD; k++) { + const upcoming = state.sentences[state.idx + k]; + if (upcoming) magSynth(voice, upcoming.text).catch(() => {}); + } + } + blob = await pending; } catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; @@ -1892,16 +2084,37 @@ async function magLoadVoices() { const id = v.id || v.name || v; return ''; }).join(''); - ['mag-voice', 'mag-host-a', 'mag-host-b', 'mag-host-c'].forEach((id) => { + // The app-wide searchable picker (voice-picker.js) hides the ). + const trigger = document.querySelector('.vp-root[data-vp-for="' + id + '"] .vp-trigger'); + if (trigger) { + trigger.setAttribute('role', 'button'); + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-label', voiceLabels[id]); + } } + if (keep) setVoice(id, keep); }); const def = state.doc && state.doc.default_voice; - if (def && $('mag-voice') && !$('mag-voice').value) $('mag-voice').value = def; + if (def && $('mag-voice') && !$('mag-voice').value) setVoice('mag-voice', def); } async function loadPodcastIfAny() { @@ -2108,9 +2321,13 @@ root.magIngest = magIngest; root.magReview = magReview; root.magReviewGoPage = magReviewGoPage; root.magReviewGoMagazinePage = magReviewGoMagazinePage; +root.magReviewStepPage = magReviewStepPage; +root.magReviewToggleSpread = magReviewToggleSpread; +root.magReviewSetActivePage = magReviewSetActivePage; root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewToggleBox = magReviewToggleBox; +root.magReviewClearCurrentPage = magReviewClearCurrentPage; root.magReocrPage = magReocrPage; root.magRefineFigures = magRefineFigures; root.magReviewToggleImage = magReviewToggleImage; @@ -2119,12 +2336,17 @@ root.magSaveImageDescription = magSaveImageDescription; root.magReviewToggleDraw = magReviewToggleDraw; root.magReviewCancelDrawPanel = magReviewCancelDrawPanel; root.magReviewSubmitDraw = magReviewSubmitDraw; +root.magReviewToggleEdit = magReviewToggleEdit; +root.magReviewCancelEditPanel = magReviewCancelEditPanel; +root.magReviewSubmitEdit = magReviewSubmitEdit; +root.magReviewDeleteRegion = magReviewDeleteRegion; root.magReocrMagazine = magReocrMagazine; root.magApprove = magApprove; root.magSetStatus = magSetStatus; root.magRead = magRead; root.magSeek = magSeek; root.magPlay = magPlay; +root.magPrepareAudio = magPrepareAudio; root.magPause = magPause; root.magSetPane = magSetPane; root.magPeekPage = magPeekPage; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 4f28682..c9acb48 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -116,13 +116,19 @@
-