vomebook commited on
Commit
1cbbbea
·
1 Parent(s): e41749f

fix(reader): do not pull the reader back after scrolling away from speech

Browse files

Auto-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 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 (!self.following || sourceInfo.root(part.unit) || !sourceInfo.locate) return;
214
- return Promise.resolve(sourceInfo.locate(part.unit)).then(() => until(() => sourceInfo.root(part.unit), 8000));
 
 
215
  }
216
- const rect = range.getBoundingClientRect();
217
- if (!rect.height) return;
 
 
 
 
 
 
 
 
 
 
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
- if (self.following && (bottom > box.bottom - 24 || top < box.top)) {
222
- range.startContainer.parentElement?.scrollIntoView({ block: "center", behavior: "smooth" });
223
- return;
224
- }
225
- self.following = bottom > box.top && top < box.bottom;
 
226
  },
227
- following: true,
228
  async reveal(offset) {
229
  const part = self.partAt(offset);
230
  if (!part) return;
231
- await sourceInfo.locate?.(part.unit);
232
- if (adapter !== self) publish(self);
233
- const range = self.range(offset, offset + 1);
234
- range?.startContainer.parentElement?.scrollIntoView({ block: "center" });
235
- self.following = true;
 
 
 
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 = () => { clearTimeout(timer); timer = setTimeout(syncToView, 250); };
374
- listen(readerContext.viewport, "scroll", onScroll, { passive: true });
 
 
 
 
 
 
 
 
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: