Optimize and clean up the pipeline merge (v1.12.85)
Speed: OCR renders only the heading band (was full page at 2.5x),
Tesseract worker freed after extraction, name-underline index cached
instead of rebuilt per segment, constant regexes hoisted, old drafts
migrate segment page numbers once at load (single-path feed renderer).
Quality: global [hidden]{display:none!important} ends the empty-box bug
class; racy deferred cast-restore + _readerSuppressCastRestore flag
replaced by a synchronous, caller-wins restore; card collapse defaults
move to data-collapse-default markup; duplicated join/colour/alias/LLM-
target helpers now delegate to their canonical implementations; dead
reader state removed; stepper hide-guard fixed for the merged Source key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9d80ec27e7
commit
cfe15a72f5
19
CHANGELOG.md
19
CHANGELOG.md
@ -9,6 +9,25 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.12.85] — 2026-07-03
|
||||
|
||||
### Performance
|
||||
- **Heading OCR renders only the top band, not the whole page** — the OCR pass rasterized every full page at 2.5× scale (~6M pixels for A4) and then cropped ~10% of it; the canvas is now sized to the band itself so the remaining ~90% is never rendered or allocated. Also: the Tesseract worker (tens of MB of WASM/language data) is now terminated after extraction instead of living for the whole session, and the top-gap scan no longer allocates a throwaway array per page.
|
||||
- **Casting feed name-underlining no longer rebuilds its name index per segment** — the list of character names/aliases and their compiled regexes was recomputed for every rendered segment (O(segments × records) over a full book); it's now cached and invalidated only when the roster or character records actually change. The dialogue-splitting fallback also compiled a constant regex once per sentence-ending character; hoisted to a module constant.
|
||||
- **Old casting drafts migrate their page numbers once at load** — drafts saved before segments carried a `.page` field were re-deriving page boundaries via text search on every feed redraw; they're now stamped once when the draft is applied, and the renderer is single-path.
|
||||
|
||||
### Changed
|
||||
- **`[hidden]` now always hides, globally** — one root rule (`[hidden]{display:none!important}`) replaces the per-component patches this bug class kept requiring (`.ab-char-bar`, `.wf-stepper`, `.reader-extract-banner`, `.ab-castpanel-inline`, and ~20 others individually). New components can no longer reintroduce the empty-box-while-hidden bug.
|
||||
- **Removed the racy cast-panel view override** — navigating to Read Aloud restored an active casting session via a deferred `setTimeout` that overrode whatever view the caller had just chosen, which needed a global suppress flag (`_readerSuppressCastRestore`) set from two unrelated places to defeat. The restore is now synchronous and respects an explicit view request, so the flag is gone and callers simply win by calling `showReaderView()` after `navTo()`. This also fixes the sidebar "Reader" item landing in the casting view instead of the reader while a cast was active.
|
||||
- **Cards can declare their collapse default in markup** — `data-collapse-default="closed"` on a card is now read by the generic collapse mechanism, replacing reader.js writing another module's localStorage key derived from the card's heading text (which would have silently broken on any heading rename).
|
||||
- **Deduplicated helpers** — casting's segment-join, colour (hue/hex/normalize), and alias-splitting logic now delegate to the canonical implementations (`audiobookJoinSegmentText`, `clNormalizeColor`/`clHslToHex`/`clNameHue`, `clSplitIdentityTokens`) instead of maintaining byte-identical private copies; the status bar's LLM endpoint resolution is now a single shared `statusLlmTarget()` instead of two identical inline copies.
|
||||
- **Removed dead state** — `readerState.pdfParsing`/`pdfParsedPages`/`pdfParseTotal` (never set, only reset — including a toast suffix that could never appear) and the write-only `readerState.textExtracted` flag.
|
||||
|
||||
### Fixed
|
||||
- **Pipeline stepper never hid on a fresh session** — the "show only when a document is loaded" guard still checked for the old `'pdf'` step key after the PDF/Text merge into "Source", so the always-available Source stop kept the strip permanently visible.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.84] — 2026-07-03
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -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.12.84">
|
||||
<meta name="app-version" content="1.12.85">
|
||||
<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,7 +27,7 @@
|
||||
|
||||
<!-- ── 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.12.84">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.85">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.12.84"></script>
|
||||
<script src="/static/loader.js?v=1.12.85"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -652,7 +652,10 @@ window.initCollapsibleCards = function initCollapsibleCards() {
|
||||
card.dataset.colInit = '1';
|
||||
|
||||
const key = 'card-' + slug(h2.textContent);
|
||||
const open = load()[key] !== false; // default: open
|
||||
// A card can opt into starting collapsed via data-collapse-default="closed";
|
||||
// a stored user choice always wins over the default.
|
||||
const stored = load()[key];
|
||||
const open = stored !== undefined ? stored !== false : card.dataset.collapseDefault !== 'closed';
|
||||
|
||||
// Prepend a rotating chevron inside the h2
|
||||
const chev = document.createElement('span');
|
||||
|
||||
@ -165,10 +165,6 @@ function audiobookFindReturnedSegmentSequence(targetSeg, returned, used) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Same hue algorithm as library.js _charHue so avatar colours match across views
|
||||
function _abCharHue(name) {
|
||||
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
|
||||
}
|
||||
// String coercer (mirrors library.js _libStr)
|
||||
function _abStr(v) {
|
||||
if (v == null) return '';
|
||||
@ -228,6 +224,32 @@ function _abCastMarkdown(data) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// One-time migration for drafts saved before segments carried a .page number:
|
||||
// re-derive each segment's page from the draft's pageMarks via the old
|
||||
// forward-only offset search and stamp it on. Mirrors the legacy render-time
|
||||
// fallback exactly (segments before the first mark stay unstamped → unlabeled
|
||||
// leading card), but runs once at draft load instead of on every redraw, so
|
||||
// the feed renderer stays single-path.
|
||||
function _abStampSegmentPages(segs, pageMarks, text) {
|
||||
if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return;
|
||||
const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
|
||||
const src = text || '';
|
||||
if (!marks.length || !src) return;
|
||||
let markIdx = 0, searchPos = 0, cur = null;
|
||||
if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; }
|
||||
for (const s of segs) {
|
||||
const probe = String(s.text || '').trim().slice(0, 24);
|
||||
const at = probe ? src.indexOf(probe, searchPos) : -1;
|
||||
const pos = at >= 0 ? at : searchPos;
|
||||
if (at >= 0) searchPos = at + probe.length;
|
||||
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
|
||||
cur = marks[markIdx].page + 1;
|
||||
markIdx++;
|
||||
}
|
||||
if (cur != null) s.page = cur;
|
||||
}
|
||||
}
|
||||
|
||||
function _abSaveDraft(segs, roster, text, done, total) {
|
||||
const bookId = _abBookId();
|
||||
const payload = {
|
||||
@ -515,6 +537,12 @@ const AB_QUOTE_PAIRS = {
|
||||
'「': '」', '『': '』', '‘': '’', '‚': '‘', '›': '‹', '‹': '›'
|
||||
};
|
||||
|
||||
// Constant pattern — compiled once, not per sentence-ending character scanned.
|
||||
const _AB_UNCLOSED_TAG_RE = new RegExp(
|
||||
'^\\s*(?:[,;:–-]\\s*)?(?:' + AB_SPEECH_VERBS + '|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+' + AB_SPEECH_VERBS + ')\\b',
|
||||
'i'
|
||||
);
|
||||
|
||||
function _abUnclosedQuoteEnd(raw, closeIdx) {
|
||||
const limit = closeIdx >= 0 ? closeIdx : raw.length;
|
||||
for (let i = 0; i < limit; i++) {
|
||||
@ -522,11 +550,7 @@ function _abUnclosedQuoteEnd(raw, closeIdx) {
|
||||
const quote = raw.slice(0, i + 1).trim();
|
||||
if (quote.length < 2) continue;
|
||||
const after = raw.slice(i + 1, Math.min(limit, i + 140));
|
||||
const tag = new RegExp(
|
||||
'^\\s*(?:[,;:–-]\\s*)?(?:' + AB_SPEECH_VERBS + '|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+' + AB_SPEECH_VERBS + ')\\b',
|
||||
'i'
|
||||
);
|
||||
if (tag.test(after)) return i + 1;
|
||||
if (_AB_UNCLOSED_TAG_RE.test(after)) return i + 1;
|
||||
}
|
||||
if (closeIdx < 0) {
|
||||
const m = raw.match(/^([\s\S]{2,180}?[.!?])(?:\s|$)/);
|
||||
@ -715,20 +739,12 @@ function audiobookProgress(total) {
|
||||
};
|
||||
}
|
||||
|
||||
function _abHslToHex(h, s, l) {
|
||||
s /= 100; l /= 100;
|
||||
const k = n => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||
return '#' + [f(0), f(8), f(4)].map(x => Math.round(255 * x).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Delegates to the character library's canonical colour implementation
|
||||
// (clNormalizeColor / clHslToHex / clNameHue in characters-library.js) so
|
||||
// avatar colours can't drift between views; only the 'Narrator' default for a
|
||||
// missing name lives here.
|
||||
function _abNormalizeColor(color, fallbackName) {
|
||||
const c = String(color || '').trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(c)) return c;
|
||||
if (/^#[0-9a-f]{3}$/i.test(c)) return '#' + c.slice(1).split('').map(ch => ch + ch).join('');
|
||||
const hue = _abCharHue(fallbackName || 'Narrator');
|
||||
return _abHslToHex(hue, 58, 43);
|
||||
return clNormalizeColor(color, fallbackName || 'Narrator');
|
||||
}
|
||||
|
||||
function _abDefaultCharacterColor(name) {
|
||||
@ -1146,28 +1162,22 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars');
|
||||
const roster = new Map(); // name -> { count, color }
|
||||
const characterRecords = new Map(); // lower-case name -> character-library record
|
||||
const AB_ALIAS_MAX_TOKENS = 12;
|
||||
const AB_ALIAS_MAX_CHARS = 500;
|
||||
const splitIdentityTokens = (v, opts = {}) => {
|
||||
const raw = _abStr(v);
|
||||
const parts = raw
|
||||
.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i)
|
||||
.map(x => x.trim())
|
||||
.filter(Boolean)
|
||||
.filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x));
|
||||
if (opts.aliases && (raw.length > AB_ALIAS_MAX_CHARS || parts.length > AB_ALIAS_MAX_TOKENS)) return [];
|
||||
return parts.slice(0, opts.aliases ? AB_ALIAS_MAX_TOKENS : undefined);
|
||||
};
|
||||
// Like clIdentityNames (characters-library.js) but preserves original case —
|
||||
// highlightText dedups and colour-keys by the display-cased name. The token
|
||||
// splitting itself is the library's canonical clSplitIdentityTokens.
|
||||
const identityNames = (recOrSheet) => {
|
||||
const s = recOrSheet?.sheet || recOrSheet || {};
|
||||
const out = new Set();
|
||||
const add = (v, opts = {}) => splitIdentityTokens(v, opts).forEach(x => out.add(x));
|
||||
const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x));
|
||||
add(recOrSheet?.name || s.name);
|
||||
['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' }));
|
||||
return [...out];
|
||||
};
|
||||
let _hlVer = 0; // bumped whenever character records change
|
||||
let _hlCache = { ver: -1, rosterSize: -1, list: [] };
|
||||
const registerCharacterRecord = (rec) => {
|
||||
if (!rec?.name) return;
|
||||
_hlVer++;
|
||||
for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec);
|
||||
};
|
||||
const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null;
|
||||
@ -1191,18 +1201,33 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
const highlightText = (text) => {
|
||||
if (!text) return '';
|
||||
let html = escHtml(text);
|
||||
const extraNames = [];
|
||||
new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec)));
|
||||
const names = [...new Set([...roster.keys(), ...extraNames])]
|
||||
.filter(n => n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const name of names) {
|
||||
if (name.length < 2) continue;
|
||||
const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`\\b(${safeName})\\b`, 'gi');
|
||||
html = html.replace(regex, (match) => {
|
||||
return `<span style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`;
|
||||
});
|
||||
// The name list + compiled regexes are invariant between segments until the
|
||||
// roster or character records change — rebuilding them per segment made
|
||||
// rendering O(segments × records) over a whole book. Colours stay live via
|
||||
// colorFor at replace time.
|
||||
if (_hlCache.ver !== _hlVer || _hlCache.rosterSize !== roster.size) {
|
||||
const extraNames = [];
|
||||
new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec)));
|
||||
const seen = new Set();
|
||||
const names = [];
|
||||
for (const n of [...roster.keys(), ...extraNames]) {
|
||||
const k = n.toLowerCase();
|
||||
if (n.length < 2 || k === 'narrator' || /^unknown|unbekannt/i.test(k) || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
names.push(n);
|
||||
}
|
||||
names.sort((a, b) => b.length - a.length);
|
||||
_hlCache = {
|
||||
ver: _hlVer,
|
||||
rosterSize: roster.size,
|
||||
list: names.map(name => ({
|
||||
name,
|
||||
regex: new RegExp(`\\b(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
for (const { name, regex } of _hlCache.list) {
|
||||
html = html.replace(regex, (match) => `<span style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
@ -1336,7 +1361,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
|
||||
const sh = rec.sheet || {};
|
||||
const charColor = setCharacterColor(rec.name, rec.color || sh.color || colorFor(rec.name, rec));
|
||||
const accent2 = _abHslToHex((_abCharHue(rec.name) + 40) % 360, 58, 30);
|
||||
const accent2 = clHslToHex((clNameHue(rec.name) + 40) % 360, 58, 30);
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur';
|
||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||||
@ -1848,43 +1873,18 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : '';
|
||||
feed.innerHTML = '';
|
||||
_abCurPage = null;
|
||||
// Segments cast since the .page-stamping fix carry their own page number —
|
||||
// use that directly instead of re-guessing offsets from text, which used to
|
||||
// silently merge pages whenever a segment's text didn't substring-match
|
||||
// exactly (LLM cleanup, dehyphenation, short lines) after navigating away
|
||||
// from the casting panel and back. Older drafts without a .page field fall
|
||||
// back to the previous offset-matching approach.
|
||||
const hasPageField = (segs || []).some(s => s.page != null);
|
||||
if (hasPageField) {
|
||||
let lastPage = null;
|
||||
for (const s of segs || []) {
|
||||
if (s.page != null && s.page !== lastPage) {
|
||||
_abNewPage('Page ' + s.page, s.page);
|
||||
lastPage = s.page;
|
||||
}
|
||||
_abPage().appendChild(_abRowFromSegment(s));
|
||||
}
|
||||
} else {
|
||||
const marks = (_audiobook.pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
|
||||
const src = _audiobook.lastText || '';
|
||||
let markIdx = 0, searchPos = 0;
|
||||
if (marks.length && marks[0].offset <= 2) {
|
||||
_abNewPage('Page ' + (marks[0].page + 1), marks[0].page + 1);
|
||||
markIdx = 1;
|
||||
}
|
||||
for (const s of segs || []) {
|
||||
if (src && markIdx < marks.length) {
|
||||
const probe = String(s.text || '').trim().slice(0, 24);
|
||||
const at = probe ? src.indexOf(probe, searchPos) : -1;
|
||||
const pos = at >= 0 ? at : searchPos;
|
||||
if (at >= 0) searchPos = at + probe.length;
|
||||
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
|
||||
_abNewPage('Page ' + (marks[markIdx].page + 1), marks[markIdx].page + 1);
|
||||
markIdx++;
|
||||
}
|
||||
}
|
||||
_abPage().appendChild(_abRowFromSegment(s));
|
||||
// Segments carry their own page number — stamped at cast time for new
|
||||
// casts, and back-filled once at draft load by _abStampSegmentPages for
|
||||
// drafts saved before the .page field existed. Rendering directly from it
|
||||
// (instead of re-guessing offsets from text on every redraw) is what fixed
|
||||
// pages silently merging after navigating away from the panel and back.
|
||||
let lastPage = null;
|
||||
for (const s of segs || []) {
|
||||
if (s.page != null && s.page !== lastPage) {
|
||||
_abNewPage('Page ' + s.page, s.page);
|
||||
lastPage = s.page;
|
||||
}
|
||||
_abPage().appendChild(_abRowFromSegment(s));
|
||||
}
|
||||
_abRecountRoster(segs || []);
|
||||
_abClearHL();
|
||||
@ -2046,16 +2046,6 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
|
||||
const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker);
|
||||
const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker);
|
||||
const _abJoinSegmentText = (a, b) => {
|
||||
const left = String(a || '');
|
||||
const right = String(b || '');
|
||||
if (!left) return right;
|
||||
if (!right) return left;
|
||||
if (/\s$/.test(left) || /^\s/.test(right)) return left + right;
|
||||
if (/^[,.;:!?»«”"')\]]/.test(right)) return left + right;
|
||||
if (/[([{„“"']$/.test(left)) return left + right;
|
||||
return left + ' ' + right;
|
||||
};
|
||||
const _abMergedSegment = (a, b) => {
|
||||
let base = a;
|
||||
if (_abIsUnknownSeg(a) && !_abIsUnknownSeg(b)) base = b;
|
||||
@ -2066,7 +2056,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
speaker,
|
||||
type,
|
||||
emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '',
|
||||
text: _abJoinSegmentText(a.text || '', b.text || ''),
|
||||
text: audiobookJoinSegmentText(a.text || '', b.text || ''),
|
||||
};
|
||||
};
|
||||
const _abMergeByRow = (row, dir) => {
|
||||
@ -2908,6 +2898,7 @@ async function audiobookOpenCastView() {
|
||||
const _applyDraft = (draft, view, source) => {
|
||||
const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0;
|
||||
const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0;
|
||||
_abStampSegmentPages(draft.segments, draft.pageMarks, text); // pre-.page drafts: stamp once so the renderer stays single-path
|
||||
_audiobook.segments = draft.segments;
|
||||
_audiobook.roster = draft.roster || [];
|
||||
_audiobook.lastText = text;
|
||||
|
||||
@ -94,10 +94,10 @@ async function libraryRenderBooks() {
|
||||
const id = el.dataset.id, title = el.dataset.title;
|
||||
el.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.reh-book-act')) return;
|
||||
// Show the reader on the PDF view immediately and suppress the stale-cast
|
||||
// restore so a freshly-opened book never lands in an empty casting panel.
|
||||
// Show the reader on the PDF view immediately — an explicit start view
|
||||
// also skips nav.js's cast-session restore, so a freshly-opened book
|
||||
// never lands in a stale casting panel.
|
||||
window._readerStartView = 'main';
|
||||
window._readerSuppressCastRestore = true;
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id);
|
||||
});
|
||||
|
||||
@ -48,32 +48,11 @@ const readerState = {
|
||||
savedId: null, // server library doc id when this doc came from / was saved to the library
|
||||
savedAudioIdx: new Set(), // unit indices whose audio is already on the server (incremental save / lazy fetch)
|
||||
sourceUploaded: false, // the source document has been uploaded to the server
|
||||
pdfParsing: false,
|
||||
pdfParsedPages: 0,
|
||||
pdfParseTotal: 0,
|
||||
ocrWorker: null, // lazily-created Tesseract.js worker, reused across imports
|
||||
ocrWorker: null, // lazily-created Tesseract.js worker (terminated after each extraction)
|
||||
synthStarted: false, // true once any unit leaves 'pending' — gates the red "not synthesised" tint
|
||||
textExtracted: false, // PDF pages are loaded/visible but getTextContent()+OCR hasn't run yet until this flips true
|
||||
};
|
||||
window.readerState = readerState;
|
||||
|
||||
// Default the "Voice & synthesis settings" card to collapsed the first time
|
||||
// anyone visits — it's secondary until you actually have a document loaded,
|
||||
// and was one of several boxes making the empty-state reader feel cluttered.
|
||||
// Uses the same localStorage-key convention as the generic card-collapse
|
||||
// mechanism (ai-backends.js, which runs later in a background batch) — only
|
||||
// sets a default, never overrides a choice you've already made.
|
||||
(function () {
|
||||
try {
|
||||
const KEY = 'card-collapse-v1';
|
||||
const s = JSON.parse(localStorage.getItem(KEY) || '{}');
|
||||
if (s['card-voice-synthesis-settings'] === undefined) {
|
||||
s['card-voice-synthesis-settings'] = false;
|
||||
localStorage.setItem(KEY, JSON.stringify(s));
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
|
||||
const READER_RESUME_KEY = 'reader-resume';
|
||||
const READER_FMT = 'mp3'; // synthesise + store as MP3 (compact; decodeAudioData plays it)
|
||||
const READER_BUF_WINDOW = 3; // keep decoded PCM only for ±N units around the playhead
|
||||
@ -197,10 +176,9 @@ function readerFinishLoadedDocument({ resume = true, successPrefix = 'Loaded' }
|
||||
const resumed = resume && readerLoadResume();
|
||||
readerUpdateProgress();
|
||||
readerHighlightSentence(readerState.sentences[readerState.idx]);
|
||||
const parsingNote = readerState.pdfParsing ? ' · indexing continues in background' : '';
|
||||
toast(resumed
|
||||
? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
|
||||
: successPrefix + ' · ' + readerState.sentences.length + ' sentences' + parsingNote + ' — press play', 'success');
|
||||
: successPrefix + ' · ' + readerState.sentences.length + ' sentences — press play', 'success');
|
||||
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
|
||||
return true;
|
||||
}
|
||||
@ -305,10 +283,6 @@ function readerResetDoc() {
|
||||
readerState.savedId = null;
|
||||
readerState.savedAudioIdx = new Set();
|
||||
readerState.sourceUploaded = false;
|
||||
readerState.pdfParsing = false;
|
||||
readerState.pdfParsedPages = 0;
|
||||
readerState.pdfParseTotal = 0;
|
||||
readerState.textExtracted = false;
|
||||
const extractBanner = $('reader-extract-banner'); if (extractBanner) extractBanner.hidden = true;
|
||||
if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
|
||||
readerState.synthRunning = false;
|
||||
@ -411,20 +385,18 @@ async function readerGetOcrWorker() {
|
||||
// synthetic word entries (same shape as real getTextContent words) spread
|
||||
// across the band so they slot into the normal sentence/highlight pipeline.
|
||||
async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
|
||||
let full, crop;
|
||||
let crop;
|
||||
try {
|
||||
const worker = await readerGetOcrWorker();
|
||||
const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE });
|
||||
full = document.createElement('canvas');
|
||||
full.width = viewport.width;
|
||||
full.height = viewport.height;
|
||||
await page.render({ canvasContext: full.getContext('2d'), viewport }).promise;
|
||||
|
||||
// The band is at the top of the page, so a canvas that is only cropH tall
|
||||
// captures it directly — the 2D context clips the rest of the render, so
|
||||
// we never allocate or rasterize the remaining ~90% of the page.
|
||||
const cropH = Math.max(Math.round(gapPx * READER_OCR_RENDER_SCALE), 1);
|
||||
crop = document.createElement('canvas');
|
||||
crop.width = full.width;
|
||||
crop.width = viewport.width;
|
||||
crop.height = cropH;
|
||||
crop.getContext('2d').drawImage(full, 0, 0, full.width, cropH, 0, 0, full.width, cropH);
|
||||
await page.render({ canvasContext: crop.getContext('2d'), viewport }).promise;
|
||||
|
||||
const { data } = await worker.recognize(crop);
|
||||
const text = (data.text || '').replace(/\s+/g, ' ').trim();
|
||||
@ -445,7 +417,6 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
|
||||
console.warn('Heading OCR failed', e);
|
||||
return [];
|
||||
} finally {
|
||||
if (full) { full.width = 0; full.height = 0; }
|
||||
if (crop) { crop.width = 0; crop.height = 0; }
|
||||
}
|
||||
}
|
||||
@ -469,7 +440,6 @@ async function readerLoadPdfSkeleton(file) {
|
||||
if (seq !== readerState._seq) return null;
|
||||
readerState.mode = 'pdf';
|
||||
readerState.pdfDoc = pdf;
|
||||
readerState.textExtracted = false;
|
||||
const doc = $('reader-doc');
|
||||
const nPages = pdf.numPages;
|
||||
|
||||
@ -553,7 +523,10 @@ async function readerExtractPdfText(loaded) {
|
||||
// A tall text-free band at the top of the page likely holds an image
|
||||
// heading (chapter title graphics) that getTextContent() can't see.
|
||||
if (ocrHeadingsEnabled) {
|
||||
const gapPx = pageWords.length ? Math.min(base.height, ...pageWords.map(w => w.top)) : base.height;
|
||||
// Plain loop, not Math.min(...map()) — spreading a word-dense page's
|
||||
// array into arguments allocates per page and can overflow the arg limit.
|
||||
let gapPx = base.height;
|
||||
for (const w of pageWords) if (w.top < gapPx) gapPx = w.top;
|
||||
if (gapPx > Math.max(base.height * READER_OCR_MIN_GAP_RATIO, READER_OCR_MIN_GAP_PX)) {
|
||||
const ocrWords = await readerOcrPageHeading(page, base, pageIdx, gapPx);
|
||||
if (seq !== readerState._seq) return;
|
||||
@ -576,7 +549,12 @@ async function readerExtractPdfText(loaded) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
progress.done();
|
||||
readerState.textExtracted = true;
|
||||
// Free the Tesseract worker's WASM/lang-data memory (tens of MB) now that
|
||||
// extraction is over; the next import recreates it lazily.
|
||||
if (readerState.ocrWorker) {
|
||||
try { readerState.ocrWorker.terminate(); } catch (_) {}
|
||||
readerState.ocrWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience wrapper used by restore paths (library reopen) where extraction
|
||||
|
||||
@ -102,11 +102,21 @@ function statusActiveStt() {
|
||||
};
|
||||
}
|
||||
|
||||
function statusActiveLlm() {
|
||||
// Resolve the effective LLM endpoint+model (app settings, falling back to the
|
||||
// rehearser's or conversation's pickers) — shared by the status chip and the
|
||||
// engine health check so the resolution order can't drift between them.
|
||||
function statusLlmTarget() {
|
||||
let settings = {};
|
||||
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
|
||||
const url = statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || '');
|
||||
const model = settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '';
|
||||
return {
|
||||
url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''),
|
||||
model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '',
|
||||
apiKey: settings.llm_api_key || '',
|
||||
};
|
||||
}
|
||||
|
||||
function statusActiveLlm() {
|
||||
const { url, model } = statusLlmTarget();
|
||||
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
|
||||
const ok = !!url && same && STATUS_LLM_CACHE.ok;
|
||||
return {
|
||||
@ -137,18 +147,14 @@ function updateStatusBar() {
|
||||
|
||||
async function refreshStatusBarEngines({ force = false } = {}) {
|
||||
updateStatusBar();
|
||||
let settings = {};
|
||||
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
|
||||
const url = statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || '');
|
||||
const model = settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '';
|
||||
const { url, model, apiKey } = statusLlmTarget();
|
||||
if (!url || STATUS_LLM_CACHE.pending) return;
|
||||
const fresh = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model && (Date.now() - STATUS_LLM_CACHE.checked) < 30000;
|
||||
if (fresh && !force) return;
|
||||
STATUS_LLM_CACHE.pending = true;
|
||||
try {
|
||||
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
||||
const key = settings.llm_api_key || '';
|
||||
if (key) fetchUrl += '&api_key=' + encodeURIComponent(key);
|
||||
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
|
||||
const data = await fetch(fetchUrl).then(r => r.json());
|
||||
const models = Array.isArray(data.models) ? data.models : [];
|
||||
STATUS_LLM_CACHE.url = url;
|
||||
@ -704,11 +710,8 @@ const WF_STEPS = [
|
||||
|
||||
function workflowCrumbGo(key) {
|
||||
if (key === 'source') {
|
||||
// navTo('s-reader') auto-restores an active casting panel a tick later
|
||||
// (nav.js's showSection) — without suppressing that, it silently undoes
|
||||
// the showReaderView('main') below and clicking "Source" looked like
|
||||
// it did nothing.
|
||||
window._readerSuppressCastRestore = true;
|
||||
// navTo's cast-session restore is synchronous, so the explicit view choice
|
||||
// below simply wins by running after it.
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
if (typeof showReaderView === 'function') showReaderView('main');
|
||||
} else if (key === 'cast') {
|
||||
@ -735,7 +738,9 @@ function refreshWorkflowCrumbs(active) {
|
||||
const title = window.readerState?.title || window.rehState?.title || '';
|
||||
const containers = document.querySelectorAll('.wf-stepper');
|
||||
if (!containers.length) return;
|
||||
const anyState = WF_STEPS.some(st => st.key !== 'pdf' && st.enabled());
|
||||
// Hide the strip until some stage beyond the always-available "Source" is
|
||||
// reachable — otherwise a fresh session shows a stepper with one live stop.
|
||||
const anyState = WF_STEPS.some(st => st.key !== 'source' && st.enabled());
|
||||
containers.forEach(el => {
|
||||
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
|
||||
el.hidden = false;
|
||||
|
||||
@ -145,20 +145,20 @@
|
||||
if (!SECTIONS.includes(sectionId)) sectionId = 's-voices';
|
||||
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
|
||||
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
|
||||
// Capture before readerOnShow (async) eventually consumes it: a caller that
|
||||
// requested a specific reader view via _readerStartView must not have that
|
||||
// choice overridden by the cast-session restore below.
|
||||
var _explicitReaderView = window._readerStartView;
|
||||
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
||||
// If a cast session panel is active, restore it instead of showing the main PDF
|
||||
// view — UNLESS we're opening a fresh book (which sets _readerSuppressCastRestore),
|
||||
// in which case a leftover panel must not hijack the reader into an empty cast view.
|
||||
if (sectionId === 's-reader') {
|
||||
if (window._readerSuppressCastRestore) {
|
||||
window._readerSuppressCastRestore = false;
|
||||
} else {
|
||||
var _castPnl = document.getElementById('reader-audiobook-panel');
|
||||
if (_castPnl && _castPnl.querySelector('#ab-cv-feed')) {
|
||||
setTimeout(function () {
|
||||
if (typeof window.showReaderView === 'function') window.showReaderView('cast');
|
||||
}, 0);
|
||||
}
|
||||
// If a cast session panel is active, restore it instead of showing the main
|
||||
// PDF view. Synchronous on purpose: callers that want a different view just
|
||||
// call showReaderView(...) after navTo() and their choice wins — no deferred
|
||||
// override racing whatever the caller set (the old setTimeout version hijacked
|
||||
// the view a tick later and needed a global suppress flag to defeat).
|
||||
if (sectionId === 's-reader' && !_explicitReaderView) {
|
||||
var _castPnl = document.getElementById('reader-audiobook-panel');
|
||||
if (_castPnl && _castPnl.querySelector('#ab-cv-feed') && typeof window.showReaderView === 'function') {
|
||||
window.showReaderView('cast');
|
||||
}
|
||||
}
|
||||
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<div id="reader-main-view">
|
||||
<!-- ① Import + controls (collapsible) ───────────────────────────── -->
|
||||
<div class="card reader-config-card" id="reader-config-card" style="padding:14px">
|
||||
<div class="card reader-config-card" id="reader-config-card" data-collapse-default="closed" style="padding:14px">
|
||||
<h2><span class="mdi mdi-tune-variant"></span> Voice & synthesis settings</h2>
|
||||
<div class="engine-setup-row" style="margin-bottom:8px">
|
||||
<div class="engine-setup-col" style="flex:1">
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
/* ── [hidden] must always hide ────────────────────────────────────────────────
|
||||
Any component that sets `display:flex/grid/block` on its base selector beats
|
||||
the UA's `[hidden]{display:none}` at equal specificity, making the element
|
||||
render as an empty box while "hidden". This bit .ab-char-bar, .wf-stepper,
|
||||
.reader-extract-banner, .ab-castpanel-inline and ~20 others individually —
|
||||
one root rule ends the whole bug class. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ── MDI icon integration ────────────────────────────────────────────────── */
|
||||
.mdi {
|
||||
vertical-align: -0.125em;
|
||||
@ -1615,7 +1623,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
direction; state lives wherever it was created (readerState/_audiobook/
|
||||
rehState), so jumping around never destroys it. */
|
||||
.wf-stepper { display: flex; align-items: flex-start; gap: 0; padding: 16px 20px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow-x: auto; }
|
||||
.wf-stepper[hidden] { display: none; }
|
||||
.wf-stepper-title { align-self: center; font-weight: 700; color: var(--text); font-size: 14px; white-space: nowrap; margin-right: 18px; padding-right: 18px; border-right: 1px solid var(--border); flex-shrink: 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wf-step {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||
@ -4899,7 +4906,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
display: flex; align-items: center; gap: 14px; margin: 12px 0; padding: 14px 16px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
|
||||
}
|
||||
.reader-extract-banner[hidden] { display: none; }
|
||||
.reader-extract-icon { font-size: 30px; color: var(--accent); flex-shrink: 0; }
|
||||
.reader-extract-info { flex: 1; min-width: 160px; }
|
||||
.reader-extract-info strong { display: block; color: var(--text); font-size: 14px; }
|
||||
@ -5037,7 +5043,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ab-castpanel-inline[hidden] { display: none !important; }
|
||||
|
||||
.ab-cv-body { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; min-width: 0; transition: grid-template-columns .15s; }
|
||||
.ab-cv-body.side-collapsed { grid-template-columns: minmax(0, 1fr) 44px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user