feat(tts): hotkey to highlight the currently-spoken sentence (#4085) (#4368)

Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the
Text to Speech shortcut section) that persists the sentence TTS is reading
aloud as a normal highlight using the user's default style/color — no text
selection, eyes-off, silent, and idempotent (a repeat press on the same
sentence is a no-op rather than a duplicate).

Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence
→ useTTSControl (which owns the TTSController) resolves the current sentence via
the new TTSController.getSpokenSentence() and relays create-tts-highlight
→ Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight
helper and persists/renders it like any other highlight.

Closes #4085

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-31 02:33:28 +08:00
committed by GitHub
parent c23c21d37d
commit ef603852b7
10 changed files with 1023 additions and 3 deletions
@@ -1,5 +1,6 @@
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor } from '@/types/book';
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { SystemSettings } from '@/types/settings';
import { FoliateView, NOTE_PREFIX } from '@/types/view';
import { Point } from '@/utils/sel';
@@ -86,3 +87,38 @@ export function removeBookNoteOverlays(view: FoliateView | null, note: BookNote)
view.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
}
}
/**
* Build a persistent highlight BookNote for a TTS-spoken sentence, or return
* `null` when one already exists at the same CFI (idempotent — pressing the
* hotkey twice on the same sentence must not create a duplicate).
*
* `now` is injected so the result is deterministic for tests. A soft-deleted
* note (`deletedAt`) or a non-annotation note (e.g. a bookmark) at the same CFI
* does not block creation — it mirrors the live-annotation predicate used by
* the selection-based highlight path in Annotator.tsx.
*/
export function buildTTSSentenceHighlight(
annotations: BookNote[],
params: {
cfi: string;
text: string;
style: HighlightStyle;
color: HighlightColor;
page?: number;
},
now: number,
): BookNote | null {
const exists = annotations.some(
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
);
if (exists) return null;
return {
id: uniqueId(),
type: 'annotation',
note: '',
createdAt: now,
updatedAt: now,
...params,
};
}