Files
Huang Xin e327d0c992 feat(tts): reuse the speaking session across paragraph & RSVP modes (#4657)
Switching into Paragraph or RSVP mode while TTS is already playing now
syncs to the live session instead of forcing a stop + restart inside the
mode. Bundles several related TTS fixes uncovered along the way.

Session reuse (enter from normal mode):
- TTSController.redispatchPosition() re-emits the current position on the
  canonical tts-position signal with a fresh sequence.
- useTTSControl answers a new tts-sync-request by replaying the current
  position then playback state (position-first so RSVP's paused handler
  can't discard it).
- Paragraph & RSVP engage following on entry and dispatch the request;
  no-op when no session exists.

RSVP refinements:
- Reusing a session skips the start dialog and the get-ready countdown
  (starts externally driven); gated on a live tts-playback-state signal
  so the countdown can't flash.
- Stopping TTS now pauses RSVP instead of resuming its own pacing.

Word-sync fixes:
- rangeTextExcludingInert honours the range offsets inside a single text
  node, fixing word-highlight drift on middle sentences of single-<span>
  paragraphs (Edge word highlighting).
- foliate-js TTS.from() starts at the sentence containing the selection,
  not the next one (submodule bump).
- Selecting a word and starting TTS now clears the selection.
- Dev-only [TTS] word-sync trace (stripped from production builds).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:47:29 +02:00

56 lines
1.9 KiB
TypeScript

// jsdom does not implement the CSS namespace; foliate-js TTS uses CSS.escape
// (mark[name="…"] lookups). Provide the standard polyfill so those paths work.
const globalWithCSS = globalThis as { CSS?: { escape?: (value: string) => string } };
if (!globalWithCSS.CSS) globalWithCSS.CSS = {};
if (typeof globalWithCSS.CSS.escape !== 'function') {
globalWithCSS.CSS.escape = (value: string): string => {
const string = String(value);
const length = string.length;
const firstCodeUnit = string.charCodeAt(0);
let result = '';
let index = -1;
while (++index < length) {
const codeUnit = string.charCodeAt(index);
if (codeUnit === 0x0000) {
result += '';
} else if (
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
codeUnit === 0x007f ||
(index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)
) {
result += '\\' + codeUnit.toString(16) + ' ';
} else if (index === 0 && length === 1 && codeUnit === 0x002d) {
result += '\\' + string.charAt(index);
} else if (
codeUnit >= 0x0080 ||
codeUnit === 0x002d ||
codeUnit === 0x005f ||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
(codeUnit >= 0x0061 && codeUnit <= 0x007a)
) {
result += string.charAt(index);
} else {
result += '\\' + string.charAt(index);
}
}
return result;
};
}
// matchMedia mock
if (typeof window !== 'undefined' && !window.matchMedia) {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList;
}