fix(reader): do not pull the reader back after scrolling away from speech
Browse filesAuto-follow is now decided by the reader's own settled scrolling: once the sentence being read is scrolled out of view, sentence changes no longer scroll back; following resumes via 回到播放位置 or by scrolling back to it. Wheel/touch/key input overrides one of our smooth scrolls in progress. Follow and 回到播放位置 scroll the sentence itself instead of its parent block (a TXT block holds 32 KiB, so the old scroll landed far from the sentence). Adds a regression test.
- static/reader/ui/reader-speech.mjs +71 -19
- tests/test_reader_speech.py +34 -0
static/reader/ui/reader-speech.mjs
CHANGED
|
@@ -138,6 +138,30 @@ export function createReaderSpeech(readerContext) {
|
|
| 138 |
return null;
|
| 139 |
}
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
// ---- Adapter ---------------------------------------------------------------
|
| 142 |
function createAdapter(sourceInfo, base) {
|
| 143 |
const parts = [];
|
|
@@ -204,35 +228,55 @@ export function createReaderSpeech(readerContext) {
|
|
| 204 |
},
|
| 205 |
// Follow only while the listener is reading along (the previous clip was
|
| 206 |
// on screen or was brought there by us); a reader who scrolled away is left alone.
|
|
|
|
|
|
|
|
|
|
| 207 |
follow(start) {
|
| 208 |
const part = self.partAt(start);
|
| 209 |
if (!part) return;
|
|
|
|
|
|
|
| 210 |
const range = self.range(start, start + 1);
|
| 211 |
if (!range) {
|
| 212 |
// An unrendered page/section: navigate there and wait for its text.
|
| 213 |
-
if (
|
| 214 |
-
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
-
const
|
| 217 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
const box = readerContext.viewport.getBoundingClientRect();
|
| 219 |
const shift = sourceInfo.frame ? sourceInfo.frame.getBoundingClientRect().top : 0;
|
| 220 |
const top = rect.top + shift, bottom = rect.bottom + shift;
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
self.
|
|
|
|
| 226 |
},
|
| 227 |
-
following: true,
|
| 228 |
async reveal(offset) {
|
| 229 |
const part = self.partAt(offset);
|
| 230 |
if (!part) return;
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
},
|
| 237 |
offsetAtTop() {
|
| 238 |
const position = topPosition();
|
|
@@ -369,11 +413,19 @@ export function createReaderSpeech(readerContext) {
|
|
| 369 |
};
|
| 370 |
listen(readerContext.viewport, "dblclick", onDoubleClick);
|
| 371 |
if (info.frame?.contentDocument) { listen(info.frame.contentDocument, "dblclick", onDoubleClick); ensureStyle(info.frame.contentDocument); }
|
| 372 |
-
let timer = 0;
|
| 373 |
-
const onScroll = () => {
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
if (info.frame?.contentWindow) listen(info.frame.contentWindow, "scroll", onScroll, { passive: true });
|
| 376 |
-
cleanup.push(() => clearTimeout(timer));
|
| 377 |
ensureStyle(document);
|
| 378 |
}
|
| 379 |
|
|
|
|
| 138 |
return null;
|
| 139 |
}
|
| 140 |
|
| 141 |
+
// Scroll the sentence itself (not its parent block: a TXT block holds 32 KiB)
|
| 142 |
+
// to a third of the way down its nearest scroller, crossing shadow roots and frames.
|
| 143 |
+
function scrollToRange(range, behavior) {
|
| 144 |
+
const rect = range.getBoundingClientRect();
|
| 145 |
+
if (!rect.height) return;
|
| 146 |
+
const doc = range.startContainer.ownerDocument;
|
| 147 |
+
for (let el = range.startContainer.parentElement; el; el = el.parentElement || el.getRootNode?.().host || null) {
|
| 148 |
+
const overflow = el.ownerDocument.defaultView.getComputedStyle(el).overflowY;
|
| 149 |
+
if (/auto|scroll/.test(overflow) && el.scrollHeight > el.clientHeight + 1) {
|
| 150 |
+
const box = el.getBoundingClientRect();
|
| 151 |
+
el.scrollBy({ top: rect.top - box.top - box.height / 3, behavior });
|
| 152 |
+
return;
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
const scroller = doc.scrollingElement;
|
| 156 |
+
if (doc !== document && scroller && scroller.scrollHeight > scroller.clientHeight + 1) {
|
| 157 |
+
doc.defaultView.scrollBy({ top: rect.top - doc.defaultView.innerHeight / 3, behavior });
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
const frame = doc.defaultView?.frameElement;
|
| 161 |
+
const box = readerContext.viewport.getBoundingClientRect();
|
| 162 |
+
readerContext.viewport.scrollBy({ top: rect.top + (frame ? frame.getBoundingClientRect().top : 0) - box.top - box.height / 3, behavior });
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
// ---- Adapter ---------------------------------------------------------------
|
| 166 |
function createAdapter(sourceInfo, base) {
|
| 167 |
const parts = [];
|
|
|
|
| 228 |
},
|
| 229 |
// Follow only while the listener is reading along (the previous clip was
|
| 230 |
// on screen or was brought there by us); a reader who scrolled away is left alone.
|
| 231 |
+
// Whether `following` holds is decided by the reader's own scrolling (see
|
| 232 |
+
// userScrolled), not by the clip boundary: scrolling the current clip out
|
| 233 |
+
// of view stops following until "回到播放位置" or scrolling back to it.
|
| 234 |
follow(start) {
|
| 235 |
const part = self.partAt(start);
|
| 236 |
if (!part) return;
|
| 237 |
+
self.current = start;
|
| 238 |
+
if (!self.following) return;
|
| 239 |
const range = self.range(start, start + 1);
|
| 240 |
if (!range) {
|
| 241 |
// An unrendered page/section: navigate there and wait for its text.
|
| 242 |
+
if (sourceInfo.root(part.unit) || !sourceInfo.locate) return;
|
| 243 |
+
self.autoUntil = performance.now() + 8000;
|
| 244 |
+
return Promise.resolve(sourceInfo.locate(part.unit)).then(() => until(() => sourceInfo.root(part.unit), 8000))
|
| 245 |
+
.finally(() => { self.autoUntil = performance.now() + 600; });
|
| 246 |
}
|
| 247 |
+
const position = self.visible(start, range);
|
| 248 |
+
if (position && position.inside) return;
|
| 249 |
+
if (position) {
|
| 250 |
+
self.autoUntil = performance.now() + 1200;
|
| 251 |
+
scrollToRange(range, "smooth");
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
following: true, current: null, autoUntil: 0,
|
| 255 |
+
// null when the clip has no rendered range; otherwise whether it is in view.
|
| 256 |
+
visible(start, range = self.range(start, start + 1)) {
|
| 257 |
+
const rect = range?.getBoundingClientRect();
|
| 258 |
+
if (!rect || !rect.height) return null;
|
| 259 |
const box = readerContext.viewport.getBoundingClientRect();
|
| 260 |
const shift = sourceInfo.frame ? sourceInfo.frame.getBoundingClientRect().top : 0;
|
| 261 |
const top = rect.top + shift, bottom = rect.bottom + shift;
|
| 262 |
+
return { inside: top >= box.top && bottom <= box.bottom - 24, overlaps: bottom > box.top && top < box.bottom };
|
| 263 |
+
},
|
| 264 |
+
// A settled scroll that we did not start: follow only if the clip is still on screen.
|
| 265 |
+
userScrolled() {
|
| 266 |
+
if (self.current === null || performance.now() < self.autoUntil) return;
|
| 267 |
+
self.following = !!self.visible(self.current)?.overlaps;
|
| 268 |
},
|
|
|
|
| 269 |
async reveal(offset) {
|
| 270 |
const part = self.partAt(offset);
|
| 271 |
if (!part) return;
|
| 272 |
+
self.autoUntil = performance.now() + 8000;
|
| 273 |
+
try {
|
| 274 |
+
await sourceInfo.locate?.(part.unit);
|
| 275 |
+
if (adapter !== self) publish(self);
|
| 276 |
+
const range = self.range(offset, offset + 1);
|
| 277 |
+
if (range) scrollToRange(range, "auto");
|
| 278 |
+
self.following = true;
|
| 279 |
+
} finally { self.autoUntil = performance.now() + 600; }
|
| 280 |
},
|
| 281 |
offsetAtTop() {
|
| 282 |
const position = topPosition();
|
|
|
|
| 413 |
};
|
| 414 |
listen(readerContext.viewport, "dblclick", onDoubleClick);
|
| 415 |
if (info.frame?.contentDocument) { listen(info.frame.contentDocument, "dblclick", onDoubleClick); ensureStyle(info.frame.contentDocument); }
|
| 416 |
+
let timer = 0, settle = 0;
|
| 417 |
+
const onScroll = () => {
|
| 418 |
+
clearTimeout(timer); timer = setTimeout(syncToView, 250);
|
| 419 |
+
clearTimeout(settle); settle = setTimeout(() => adapter?.userScrolled(), 150);
|
| 420 |
+
};
|
| 421 |
+
// Direct input overrides one of our smooth scrolls still in progress.
|
| 422 |
+
const onIntent = () => { if (adapter) adapter.autoUntil = 0; };
|
| 423 |
+
for (const target of [readerContext.viewport, info.frame?.contentDocument]) {
|
| 424 |
+
listen(target, "scroll", onScroll, { passive: true, capture: true });
|
| 425 |
+
for (const type of ["wheel", "touchstart", "keydown"]) listen(target, type, onIntent, { passive: true, capture: true });
|
| 426 |
+
}
|
| 427 |
if (info.frame?.contentWindow) listen(info.frame.contentWindow, "scroll", onScroll, { passive: true });
|
| 428 |
+
cleanup.push(() => { clearTimeout(timer); clearTimeout(settle); });
|
| 429 |
ensureStyle(document);
|
| 430 |
}
|
| 431 |
|
tests/test_reader_speech.py
CHANGED
|
@@ -129,6 +129,40 @@ class ReaderSpeechTest(unittest.TestCase):
|
|
| 129 |
finally:
|
| 130 |
context.close()
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def test_native_pdf_text_layer_is_read_and_image_is_not_offered(self):
|
| 133 |
context, page, posts, errors = self.open("pdf", minimal_pdf(), "application/pdf")
|
| 134 |
try:
|
|
|
|
| 129 |
finally:
|
| 130 |
context.close()
|
| 131 |
|
| 132 |
+
def test_scrolling_away_is_not_undone_by_the_next_sentence(self):
|
| 133 |
+
body = "".join(f"第{index}句。\n" for index in range(400)).encode()
|
| 134 |
+
context, page, posts, errors = self.open("txt", body, "text/plain; charset=utf-8")
|
| 135 |
+
try:
|
| 136 |
+
self.play(page, posts)
|
| 137 |
+
viewport = page.locator("#viewport")
|
| 138 |
+
box = viewport.bounding_box()
|
| 139 |
+
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
|
| 140 |
+
page.mouse.wheel(0, 6000)
|
| 141 |
+
page.wait_for_timeout(700)
|
| 142 |
+
away = viewport.evaluate("n => n.scrollTop")
|
| 143 |
+
self.assertGreater(away, 2000)
|
| 144 |
+
clip = page.locator("#speech-audio").evaluate("n => n.src")
|
| 145 |
+
for _ in range(2): # Two sentence changes must leave the reader where they scrolled.
|
| 146 |
+
page.locator("#speech-next").click()
|
| 147 |
+
wait(page, f"() => document.querySelector('#speech-audio').src !== {json.dumps(clip)}")
|
| 148 |
+
clip = page.locator("#speech-audio").evaluate("n => n.src")
|
| 149 |
+
page.wait_for_timeout(900)
|
| 150 |
+
self.assertEqual(viewport.evaluate("n => n.scrollTop"), away)
|
| 151 |
+
page.locator("#speech-locate").click()
|
| 152 |
+
wait(page, f"() => document.querySelector('#viewport').scrollTop < {away - 2000}", 5000)
|
| 153 |
+
# The sentence being read is on screen, not merely its 32 KiB text block.
|
| 154 |
+
self.assertTrue(page.evaluate("""() => { const range = [...CSS.highlights.get('ccrd-speech')][0], rect = range.getBoundingClientRect();
|
| 155 |
+
const box = document.querySelector('#viewport').getBoundingClientRect(); return rect.top >= box.top && rect.bottom <= box.bottom; }"""))
|
| 156 |
+
# Following resumes after returning to the playback position.
|
| 157 |
+
top = viewport.evaluate("n => n.scrollTop")
|
| 158 |
+
page.locator("#speech-next").click()
|
| 159 |
+
page.wait_for_timeout(900)
|
| 160 |
+
self.assertLess(abs(viewport.evaluate("n => n.scrollTop") - top), 400)
|
| 161 |
+
self.stop(page)
|
| 162 |
+
self.assertFalse(errors, errors)
|
| 163 |
+
finally:
|
| 164 |
+
context.close()
|
| 165 |
+
|
| 166 |
def test_native_pdf_text_layer_is_read_and_image_is_not_offered(self):
|
| 167 |
context, page, posts, errors = self.open("pdf", minimal_pdf(), "application/pdf")
|
| 168 |
try:
|