feat(magazine): fix Edit boxes, add spread view, searchable narrator picker and Prepare audio
- fix: Edit boxes and page-nav controls were dead clicks (handlers never exported to root); resize from any edge/corner instead of only bottom-right - feat: two-page spread in review (page images above, OCR lists beneath) - feat: searchable voice picker for narrator and podcast hosts - feat: Prepare audio synthesises the whole article ahead of playback; playback also stays three sentences ahead - fix: sync index.html cache-busting version with VERSION (was stuck at 1.26.12) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2dd5cea7b7
commit
e3d6cb2cca
19
CHANGELOG.md
19
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 `<select>`s were moved out of their wrapping `<label>`s: a label forwards clicks to its control, and the picker read that forwarded click as an outside click and closed itself instantly. The picker triggers get `role="button"` and an `aria-label`.
|
||||
- **Two-page spread view in Magazine review.** A new "Two-page spread" toggle next to the page navigator shows the current page next to its facing page (odd/even print pairing, e.g. 22/23), each with its own OCR-results list directly beneath it — a 2×2 layout matching how an open magazine spread reads. Only one page is draggable/drawable at a time; "Edit this page" on the other half switches which one the toolbar's draw/edit/OCR-again actions apply to, without leaving spread mode. Checkboxes and click-to-highlight work on both pages regardless of which is active.
|
||||
|
||||
### Verified
|
||||
- `node -c static/js/magazine.js` after each edit, plus a Python check that every `mag*` function named in an `onclick=`/`onchange=`/`onmouseenter=`/`onmouseleave=` attribute has a matching `root.mag* = ...` export (was non-empty before this fix, empty after).
|
||||
- `tests/magazine.spec.js`: picker replaces the select, search filters (incl. the "No voices found" state) and selects; Prepare audio synthesises each of a 4-sentence article exactly once before playback and Listen then makes no further synthesis requests; Prepare audio with no narrator is refused with zero requests. 9/9 on the desktop project (the iPhone project cannot launch in this environment — it pairs a webkit profile with the snap Chromium binary — and fails before any test runs, unrelated to this change).
|
||||
- Live via Playwright against the real ingested c't KI Wissen 2026 magazine, page 21 (the exact page/article from the bug report): clicked the real "Edit boxes" button (not a direct function call) and confirmed it now activates with no console errors; dragged region 7's bottom ("s") resize handle down ~40px with real mouse events and confirmed only its height changed (top/left/width untouched) and the Save/Save & re-OCR panel appeared; clicked Cancel so no test edit was written to the real article. Toggled "Two-page spread" and confirmed the 2×2 layout (page 20 + page 21 images on top, each page's own OCR-results list directly beneath it, "Edit this page" swapping the active/draggable page).
|
||||
|
||||
---
|
||||
|
||||
## [1.30.0] — 2026-09-15
|
||||
|
||||
### Added
|
||||
|
||||
38
Manual.md
38
Manual.md
@ -64,14 +64,24 @@ turned into a podcast discussion between two or three voices you pick.
|
||||
directly on the page image and add it as text (typed or OCRed) or as a
|
||||
new figure. If a region was found but its box is misplaced, the wrong
|
||||
size, or shouldn't exist at all, **"Edit boxes"** lets you drag an
|
||||
existing box to move it, drag its corner handle to resize it, or press
|
||||
its **×** to delete it outright. Moving or resizing opens a small confirm
|
||||
panel — **Save** keeps the region's existing text at its new position;
|
||||
for a text region, **Save & re-OCR** also reads the corrected area again
|
||||
and replaces the text with that fresh reading (useful when the box was
|
||||
too small or too large to read cleanly the first time). Deleting a
|
||||
region removes it from the page entirely, not just from the article
|
||||
currently under review. Confirm the boundaries, correct the title, then **Approve**.
|
||||
existing box to move it, drag any of its eight edge/corner handles to
|
||||
resize just that side, or press its **×** to delete it outright. The
|
||||
per-edge handles matter most when two regions share a boundary — for
|
||||
example a paragraph split wrongly between two boxes — since dragging just
|
||||
the top or bottom handle moves that one shared line without disturbing
|
||||
either box's width or its opposite edge. Moving or resizing opens a small
|
||||
confirm panel — **Save** keeps the region's existing text at its new
|
||||
position; for a text region, **Save & re-OCR** also reads the corrected
|
||||
area again and replaces the text with that fresh reading (useful when the
|
||||
box was too small or too large to read cleanly the first time, or after
|
||||
moving a shared boundary). Deleting a region removes it from the page
|
||||
entirely, not just from the article currently under review. Use
|
||||
**"Two-page spread"** to see this page next to its facing page (e.g. 22
|
||||
and 23), each with its own OCR-results list directly beneath it — useful
|
||||
for lining up a figure or a jump that crosses the gutter. Only one page
|
||||
is editable at a time; click **"Edit this page"** on the other half to
|
||||
switch which one the drawing and editing tools apply to. Confirm the
|
||||
boundaries, correct the title, then **Approve**.
|
||||
Optionally, click **"Describe"** under a figure, chart, table or code block
|
||||
to have the configured vision engine generate a one- or two-sentence
|
||||
description for listeners who cannot see the image — e.g. what a chart
|
||||
@ -81,7 +91,17 @@ turned into a podcast discussion between two or three voices you pick.
|
||||
nothing about existing narration by itself — it is only spoken if you
|
||||
also turn on "Read figure descriptions" in step 4.
|
||||
4. **Listen.** An approved article can be narrated with the default voice, or
|
||||
sent to podcast mode. Use **Play all articles** (on the contents screen) to
|
||||
sent to podcast mode. Pick the narrator (and the podcast hosts) from the
|
||||
same searchable voice picker used elsewhere in the app — open it and type
|
||||
to filter the list. Playback synthesises one sentence at a time, staying a
|
||||
few sentences ahead, so you only wait for the first. To avoid waiting at
|
||||
all, press **Prepare audio** first: it synthesises the whole article now
|
||||
(starting from where you are, then the part before it), shows progress as
|
||||
"Preparing audio 12 / 24", and can be cancelled with the same button. Once
|
||||
it says "Audio ready", **Listen** plays straight through from the prepared
|
||||
audio. Prepared audio is held in the browser only — closing or reloading
|
||||
the tab, opening another article, or changing the narrator discards it, and
|
||||
Prepare audio must be run again. Use **Play all articles** (on the contents screen) to
|
||||
listen through every approved article back to back without returning to
|
||||
this screen between them. A figure from the page is shown automatically as
|
||||
playback reaches it. **Word highlighting** can be turned off if you find it
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.26.12">
|
||||
<meta name="app-version" content="1.31.0">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.26.12">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.26.12">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.26.12">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.26.12">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.26.12">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.26.12">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.31.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.31.0">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
|
||||
@ -36,6 +36,11 @@ const state = {
|
||||
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;
|
||||
|
||||
@ -687,15 +692,20 @@ function magBlockOwner(blockId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const host = $('mag-review');
|
||||
// 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;
|
||||
if (!host || !a) return;
|
||||
const included = _magReviewChecked || new Set(a.blocks || []);
|
||||
const conf = confidenceLabel(a.confidence);
|
||||
const page = state.reviewPage;
|
||||
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
|
||||
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 || [])
|
||||
@ -707,20 +717,137 @@ function renderReview() {
|
||||
.concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order ?? 0, data: img })))
|
||||
.sort((x, y) => x.ro - y.ro);
|
||||
|
||||
const spanIdx = (state.reviewSpan || []).indexOf(page);
|
||||
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
|
||||
const nextPage = spanIdx >= 0 && spanIdx < (state.reviewSpan || []).length - 1 ? state.reviewSpan[spanIdx + 1] : null;
|
||||
const issuePages = (state.layout || {}).page_count || '—';
|
||||
const nextIssuePage = page != null && page < (state.layout || {}).page_count ? page + 1 : null;
|
||||
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 icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
|
||||
pageHtml += '<div class="mag-review-box' + (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">';
|
||||
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);
|
||||
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)">' +
|
||||
'<span class="mag-block-num">' + (i + 1) + '</span>' +
|
||||
'<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 + '"></span> ' + esc(r.data.type || 'image') + '</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)">' +
|
||||
'<span class="mag-block-num">' + (i + 1) + '</span>' +
|
||||
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
|
||||
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.region_label === 'page_number' ? 'page number' : b.type === 'body' ? 'paragraph' : b.type === 'title' || b.type === 'heading' ? 'headline' : b.type === 'byline' ? 'author' : b.type === 'sidebar' ? 'sidenote' : b.type === 'ad' ? 'advertisement' : b.type) + '</span>' +
|
||||
'<span class="mag-block-text">' +
|
||||
(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></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 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 += '<div class="mag-review-pagenav">' +
|
||||
'<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (state.reviewPageIdx > 0 ? '' : 'disabled') +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (pageIdx > 0 ? '' : 'disabled') +
|
||||
' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
|
||||
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next page" ' + (state.reviewPageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
|
||||
'<button type="button" class="btn-sm" aria-label="Next page" ' + (pageIdx >= 0 && pageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
|
||||
' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
|
||||
'<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
|
||||
@ -728,6 +855,11 @@ function renderReview() {
|
||||
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next magazine 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">' +
|
||||
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
|
||||
@ -774,84 +906,35 @@ function renderReview() {
|
||||
html += '<p class="muted">Set a language model in Settings to recognise advertisements automatically.</p>';
|
||||
}
|
||||
|
||||
html += '<div class="mag-review-split">';
|
||||
|
||||
html += '<div class="mag-review-page">';
|
||||
if (page != null) {
|
||||
html += '<div class="mag-review-page-wrap" id="mag-review-page-wrap">' +
|
||||
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
|
||||
'<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 icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
|
||||
html += '<div class="mag-review-box' + (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>' +
|
||||
'<span class="mag-review-box-resize" aria-hidden="true"></span>' +
|
||||
'</div>';
|
||||
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 {
|
||||
html += '<p class="muted">No page to show.</p>';
|
||||
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>';
|
||||
|
||||
html += '<div class="mag-blocks">';
|
||||
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 += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
|
||||
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||||
'<span class="mag-block-num">' + (i + 1) + '</span>' +
|
||||
'<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 + '"></span> ' + esc(r.data.type || 'image') + '</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);
|
||||
html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
|
||||
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||||
'<span class="mag-block-num">' + (i + 1) + '</span>' +
|
||||
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
|
||||
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.region_label === 'page_number' ? 'page number' : b.type === 'body' ? 'paragraph' : b.type === 'title' || b.type === 'heading' ? 'headline' : b.type === 'byline' ? 'author' : b.type === 'sidebar' ? 'sidenote' : b.type === 'ad' ? 'advertisement' : b.type) + '</span>' +
|
||||
'<span class="mag-block-text">' +
|
||||
(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></label>';
|
||||
});
|
||||
if (!regions.length) html += '<p class="muted">Nothing detected on this page.</p>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>'; // /mag-review-split
|
||||
|
||||
html += '<div class="mag-review-foot">' +
|
||||
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
|
||||
@ -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) =>
|
||||
'<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');
|
||||
@ -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 + ' | ||||