@@ -23,6 +23,21 @@ function decodeBase64(base64) {
2323 return decoder . decode ( base64ToBytes ( base64 ) ) ;
2424}
2525
26+ // Read Aloud annotation preview: while a highlight session is active the previewed annotation is rendered in the reader
27+ // but its saves are withheld from the app, so nothing is written to the database until the session is confirmed.
28+ let readAloudPreviewAnnotation = null ;
29+ const readAloudPreviewIds = new Set ( ) ;
30+
31+ // Serialize preview create/resize/confirm/cancel. Each is async (awaits the reader), so without a queue a rapid second
32+ // call would read a stale `readAloudPreviewAnnotation` (still null / the previous one) and create a duplicate preview
33+ // annotation — leaving an orphan and making the highlight jump or disappear as the session moves it.
34+ let readAloudPreviewQueue = Promise . resolve ( ) ;
35+ function enqueueReadAloudPreview ( task ) {
36+ readAloudPreviewQueue = readAloudPreviewQueue . then ( task ) . catch ( ( error ) => {
37+ log ( "Read Aloud preview operation failed: " + error ) ;
38+ } ) ;
39+ }
40+
2641window . createView = ( options ) => {
2742 log ( "Create " + options . type + " view" ) ;
2843 const annotations = JSON . parse ( decodeBase64 ( options . annotations ) ) ;
@@ -42,10 +57,15 @@ window.createView = (options) => {
4257 postMessage ( 'onViewContentInitialized' ) ;
4358 } ,
4459 onSaveAnnotations : ( annotations ) => {
45- postMessage ( 'onSaveAnnotations' , { annotations } ) ;
60+ // Withhold read-aloud preview annotations (not yet confirmed) so they aren't persisted mid-session.
61+ const saved = annotations . filter ( annotation => ! readAloudPreviewIds . has ( annotation . id ) ) ;
62+ if ( ! saved . length ) {
63+ return ;
64+ }
65+ postMessage ( 'onSaveAnnotations' , { annotations : saved } ) ;
4666
47- if ( annotations [ 0 ] . type == "note" ) {
48- window . _view . selectAnnotations ( [ annotations [ 0 ] . id ] ) ;
67+ if ( saved [ 0 ] . type == "note" ) {
68+ window . _view . selectAnnotations ( [ saved [ 0 ] . id ] ) ;
4969 }
5070 } ,
5171 onSetOutline : ( outline ) => {
@@ -160,11 +180,94 @@ window.getReadAloudSegments = async (options) => {
160180 postMessage ( 'onReadAloudSegments' , { requestID : options . requestID , segments } ) ;
161181} ;
162182
163- window . setReadAloudAnnotation = async ( options ) => {
164- const params = JSON . parse ( decodeBase64 ( options . params ) ) ;
165- log ( "Set Read Aloud annotation: " + params . type ) ;
166- const annotation = await window . _view . setReadAloudAnnotation ( params ) ;
167- postMessage ( 'onReadAloudAnnotation' , { requestID : options . requestID , annotation } ) ;
183+ window . getReadAloudStartBlockIndex = async ( options ) => {
184+ // The structured-document-text block index currently in view, so playback can start where the reader is. Read at
185+ // play time (not load) so it reflects the current scroll position.
186+ let blockIndex = null ;
187+ try {
188+ const sdt = await window . _view . _loadSDT ( ) ;
189+ if ( sdt ) {
190+ blockIndex = window . _view . _view . getVisibleBlockIndex ?. ( sdt . structure ) ?? null ;
191+ }
192+ }
193+ catch ( error ) {
194+ log ( "Read Aloud start block index unavailable: " + error ) ;
195+ }
196+ postMessage ( 'onReadAloudStartBlockIndex' , { requestID : options . requestID , blockIndex } ) ;
197+ } ;
198+
199+ window . setReadAloudAnnotation = ( options ) => {
200+ // Creates or resizes the highlight-session PREVIEW annotation. It renders in the reader but is withheld from the
201+ // app (see readAloudPreviewIds) until `confirmReadAloudAnnotation`. Resizes the current preview if one exists.
202+ // Queued so concurrent move/extend calls resize the single preview instead of racing to create duplicates.
203+ enqueueReadAloudPreview ( async ( ) => {
204+ const params = JSON . parse ( decodeBase64 ( options . params ) ) ;
205+ if ( readAloudPreviewAnnotation ) {
206+ params . id = readAloudPreviewAnnotation . id ;
207+ }
208+ log ( "Set Read Aloud annotation preview: " + params . type ) ;
209+ const annotation = await window . _view . setReadAloudAnnotation ( params ) ;
210+ if ( annotation ) {
211+ readAloudPreviewAnnotation = annotation ;
212+ readAloudPreviewIds . add ( annotation . id ) ;
213+ }
214+ postMessage ( 'onReadAloudAnnotation' , { requestID : options . requestID , annotation } ) ;
215+ } ) ;
216+ } ;
217+
218+ window . confirmReadAloudAnnotation = ( ) => {
219+ // Confirm the session: stop withholding the preview annotation and report it as a normal save so it is persisted.
220+ enqueueReadAloudPreview ( async ( ) => {
221+ if ( ! readAloudPreviewAnnotation ) {
222+ return ;
223+ }
224+ const annotation = readAloudPreviewAnnotation ;
225+ readAloudPreviewIds . delete ( annotation . id ) ;
226+ readAloudPreviewAnnotation = null ;
227+ log ( "Confirm Read Aloud annotation" ) ;
228+ postMessage ( 'onSaveAnnotations' , { annotations : [ annotation ] } ) ;
229+ } ) ;
230+ } ;
231+
232+ window . cancelReadAloudAnnotation = ( ) => {
233+ // Discard the session: remove the preview annotation from the reader. onDelete is a no-op on iOS, so nothing is
234+ // persisted (it was never saved). Safe no-op if already confirmed.
235+ enqueueReadAloudPreview ( async ( ) => {
236+ if ( ! readAloudPreviewAnnotation ) {
237+ return ;
238+ }
239+ const id = readAloudPreviewAnnotation . id ;
240+ readAloudPreviewIds . delete ( id ) ;
241+ readAloudPreviewAnnotation = null ;
242+ log ( "Cancel Read Aloud annotation" ) ;
243+ window . _view . unsetAnnotations ( [ id ] ) ;
244+ } ) ;
245+ } ;
246+
247+ window . setReadAloudSpotlight = async ( options ) => {
248+ // Spotlight the currently-read segment. `anchor` is an SDT position ({ start, end }); omit it (or pass null) to clear.
249+ const anchor = options . anchor ? JSON . parse ( decodeBase64 ( options . anchor ) ) : null ;
250+ log ( "Set Read Aloud spotlight: " + ( anchor ? JSON . stringify ( anchor ) : "clear" ) ) ;
251+ const position = anchor ? await window . _view . sdtAnchorToPosition ( anchor ) : null ;
252+ try {
253+ window . _view . setReadAloudSpotlight ( position ) ;
254+ }
255+ catch ( error ) {
256+ log ( "Read Aloud spotlight failed: " + error ) ;
257+ }
258+ // Follow the reading position: scroll/turn to the current segment. `navigateToSelector` moves the view to a raw
259+ // selector; the reader's own read-aloud follow (read-aloud.ts) uses it the same way. (The built-in spotlight
260+ // navigate instead passes the selector to `navigate`, which only acts on `{ position }` / `{ annotationID }`
261+ // locations and is therefore a no-op — that's why the view never moved.) Options mirror desktop: `ifNeeded` skips
262+ // the move when already visible, `block: 'center'` keeps the read text centered.
263+ if ( position ) {
264+ try {
265+ window . _view . _view . navigateToSelector ( position , { ifNeeded : true , block : 'center' , behavior : 'smooth' } ) ;
266+ }
267+ catch ( error ) {
268+ log ( "Read Aloud follow navigate failed: " + error ) ;
269+ }
270+ }
168271} ;
169272
170273// Notify when iframe is loaded
0 commit comments