forked from akai/readest
e327d0c992
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>
56 lines
1.9 KiB
TypeScript
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;
|
|
}
|