From 94eb7be3f1841c83833f9b2dbccd24c423a9bbd1 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Fri, 24 Jul 2026 15:36:46 +0200 Subject: [PATCH 1/2] Read aloud support and fixes added --- src/index.ios.js | 119 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/src/index.ios.js b/src/index.ios.js index 5da98e2f..30ce7498 100644 --- a/src/index.ios.js +++ b/src/index.ios.js @@ -23,6 +23,21 @@ function decodeBase64(base64) { return decoder.decode(base64ToBytes(base64)); } +// Read Aloud annotation preview: while a highlight session is active the previewed annotation is rendered in the reader +// but its saves are withheld from the app, so nothing is written to the database until the session is confirmed. +let readAloudPreviewAnnotation = null; +const readAloudPreviewIds = new Set(); + +// Serialize preview create/resize/confirm/cancel. Each is async (awaits the reader), so without a queue a rapid second +// call would read a stale `readAloudPreviewAnnotation` (still null / the previous one) and create a duplicate preview +// annotation — leaving an orphan and making the highlight jump or disappear as the session moves it. +let readAloudPreviewQueue = Promise.resolve(); +function enqueueReadAloudPreview(task) { + readAloudPreviewQueue = readAloudPreviewQueue.then(task).catch((error) => { + log("Read Aloud preview operation failed: " + error); + }); +} + window.createView = (options) => { log("Create " + options.type + " view"); const annotations = JSON.parse(decodeBase64(options.annotations)); @@ -42,10 +57,15 @@ window.createView = (options) => { postMessage('onViewContentInitialized'); }, onSaveAnnotations: (annotations) => { - postMessage('onSaveAnnotations', { annotations }); + // Withhold read-aloud preview annotations (not yet confirmed) so they aren't persisted mid-session. + const saved = annotations.filter(annotation => !readAloudPreviewIds.has(annotation.id)); + if (!saved.length) { + return; + } + postMessage('onSaveAnnotations', { annotations: saved }); - if (annotations[0].type == "note") { - window._view.selectAnnotations([annotations[0].id]); + if (saved[0].type == "note") { + window._view.selectAnnotations([saved[0].id]); } }, onSetOutline: (outline) => { @@ -160,11 +180,94 @@ window.getReadAloudSegments = async (options) => { postMessage('onReadAloudSegments', { requestID: options.requestID, segments }); }; -window.setReadAloudAnnotation = async (options) => { - const params = JSON.parse(decodeBase64(options.params)); - log("Set Read Aloud annotation: " + params.type); - const annotation = await window._view.setReadAloudAnnotation(params); - postMessage('onReadAloudAnnotation', { requestID: options.requestID, annotation }); +window.getReadAloudStartBlockIndex = async (options) => { + // The structured-document-text block index currently in view, so playback can start where the reader is. Read at + // play time (not load) so it reflects the current scroll position. + let blockIndex = null; + try { + const sdt = await window._view._loadSDT(); + if (sdt) { + blockIndex = window._view._view.getVisibleBlockIndex?.(sdt.structure) ?? null; + } + } + catch (error) { + log("Read Aloud start block index unavailable: " + error); + } + postMessage('onReadAloudStartBlockIndex', { requestID: options.requestID, blockIndex }); +}; + +window.setReadAloudAnnotation = (options) => { + // Creates or resizes the highlight-session PREVIEW annotation. It renders in the reader but is withheld from the + // app (see readAloudPreviewIds) until `confirmReadAloudAnnotation`. Resizes the current preview if one exists. + // Queued so concurrent move/extend calls resize the single preview instead of racing to create duplicates. + enqueueReadAloudPreview(async () => { + const params = JSON.parse(decodeBase64(options.params)); + if (readAloudPreviewAnnotation) { + params.id = readAloudPreviewAnnotation.id; + } + log("Set Read Aloud annotation preview: " + params.type); + const annotation = await window._view.setReadAloudAnnotation(params); + if (annotation) { + readAloudPreviewAnnotation = annotation; + readAloudPreviewIds.add(annotation.id); + } + postMessage('onReadAloudAnnotation', { requestID: options.requestID, annotation }); + }); +}; + +window.confirmReadAloudAnnotation = () => { + // Confirm the session: stop withholding the preview annotation and report it as a normal save so it is persisted. + enqueueReadAloudPreview(async () => { + if (!readAloudPreviewAnnotation) { + return; + } + const annotation = readAloudPreviewAnnotation; + readAloudPreviewIds.delete(annotation.id); + readAloudPreviewAnnotation = null; + log("Confirm Read Aloud annotation"); + postMessage('onSaveAnnotations', { annotations: [annotation] }); + }); +}; + +window.cancelReadAloudAnnotation = () => { + // Discard the session: remove the preview annotation from the reader. onDelete is a no-op on iOS, so nothing is + // persisted (it was never saved). Safe no-op if already confirmed. + enqueueReadAloudPreview(async () => { + if (!readAloudPreviewAnnotation) { + return; + } + const id = readAloudPreviewAnnotation.id; + readAloudPreviewIds.delete(id); + readAloudPreviewAnnotation = null; + log("Cancel Read Aloud annotation"); + window._view.unsetAnnotations([id]); + }); +}; + +window.setReadAloudSpotlight = async (options) => { + // Spotlight the currently-read segment. `anchor` is an SDT position ({ start, end }); omit it (or pass null) to clear. + const anchor = options.anchor ? JSON.parse(decodeBase64(options.anchor)) : null; + log("Set Read Aloud spotlight: " + (anchor ? JSON.stringify(anchor) : "clear")); + const position = anchor ? await window._view.sdtAnchorToPosition(anchor) : null; + try { + window._view.setReadAloudSpotlight(position); + } + catch (error) { + log("Read Aloud spotlight failed: " + error); + } + // Follow the reading position: scroll/turn to the current segment. `navigateToSelector` moves the view to a raw + // selector; the reader's own read-aloud follow (read-aloud.ts) uses it the same way. (The built-in spotlight + // navigate instead passes the selector to `navigate`, which only acts on `{ position }` / `{ annotationID }` + // locations and is therefore a no-op — that's why the view never moved.) Options mirror desktop: `ifNeeded` skips + // the move when already visible, `block: 'center'` keeps the read text centered. + if (position) { + try { + window._view._view.navigateToSelector(position, { ifNeeded: true, block: 'center', behavior: 'smooth' }); + } + catch (error) { + log("Read Aloud follow navigate failed: " + error); + } + } }; // Notify when iframe is loaded From 0e9131b7f943a8d625335d2a95089ed2e9bf8197 Mon Sep 17 00:00:00 2001 From: Michal Rentka Date: Mon, 27 Jul 2026 15:04:05 +0200 Subject: [PATCH 2/2] Code review changes --- src/common/view.js | 10 ++++++++++ src/index.ios.js | 36 ++---------------------------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/src/common/view.js b/src/common/view.js index c99ed661..221aff70 100644 --- a/src/common/view.js +++ b/src/common/view.js @@ -431,6 +431,16 @@ class View { return sdt ? sdt.mapper.sdtToSourcePosition(sdtAnchor) : null; } + /** + * Top-level structured-document-text block index currently in view, or null. Used to start Read Aloud playback + * where the reader is. + * @returns {Promise} + */ + async getVisibleBlockIndex() { + let sdt = await this._loadSDT(); + return sdt ? (this._view.getVisibleBlockIndex?.(sdt.structure) ?? null) : null; + } + async createAnnotationFromSDT({ sdtAnchor, type, color, comment, tags }) { let sdt = await this._loadSDT(); if (!sdt) { diff --git a/src/index.ios.js b/src/index.ios.js index 30ce7498..1785394f 100644 --- a/src/index.ios.js +++ b/src/index.ios.js @@ -28,9 +28,6 @@ function decodeBase64(base64) { let readAloudPreviewAnnotation = null; const readAloudPreviewIds = new Set(); -// Serialize preview create/resize/confirm/cancel. Each is async (awaits the reader), so without a queue a rapid second -// call would read a stale `readAloudPreviewAnnotation` (still null / the previous one) and create a duplicate preview -// annotation — leaving an orphan and making the highlight jump or disappear as the session moves it. let readAloudPreviewQueue = Promise.resolve(); function enqueueReadAloudPreview(task) { readAloudPreviewQueue = readAloudPreviewQueue.then(task).catch((error) => { @@ -181,14 +178,9 @@ window.getReadAloudSegments = async (options) => { }; window.getReadAloudStartBlockIndex = async (options) => { - // The structured-document-text block index currently in view, so playback can start where the reader is. Read at - // play time (not load) so it reflects the current scroll position. let blockIndex = null; try { - const sdt = await window._view._loadSDT(); - if (sdt) { - blockIndex = window._view._view.getVisibleBlockIndex?.(sdt.structure) ?? null; - } + blockIndex = await window._view.getVisibleBlockIndex(); } catch (error) { log("Read Aloud start block index unavailable: " + error); @@ -197,9 +189,6 @@ window.getReadAloudStartBlockIndex = async (options) => { }; window.setReadAloudAnnotation = (options) => { - // Creates or resizes the highlight-session PREVIEW annotation. It renders in the reader but is withheld from the - // app (see readAloudPreviewIds) until `confirmReadAloudAnnotation`. Resizes the current preview if one exists. - // Queued so concurrent move/extend calls resize the single preview instead of racing to create duplicates. enqueueReadAloudPreview(async () => { const params = JSON.parse(decodeBase64(options.params)); if (readAloudPreviewAnnotation) { @@ -230,8 +219,6 @@ window.confirmReadAloudAnnotation = () => { }; window.cancelReadAloudAnnotation = () => { - // Discard the session: remove the preview annotation from the reader. onDelete is a no-op on iOS, so nothing is - // persisted (it was never saved). Safe no-op if already confirmed. enqueueReadAloudPreview(async () => { if (!readAloudPreviewAnnotation) { return; @@ -245,29 +232,10 @@ window.cancelReadAloudAnnotation = () => { }; window.setReadAloudSpotlight = async (options) => { - // Spotlight the currently-read segment. `anchor` is an SDT position ({ start, end }); omit it (or pass null) to clear. const anchor = options.anchor ? JSON.parse(decodeBase64(options.anchor)) : null; log("Set Read Aloud spotlight: " + (anchor ? JSON.stringify(anchor) : "clear")); const position = anchor ? await window._view.sdtAnchorToPosition(anchor) : null; - try { - window._view.setReadAloudSpotlight(position); - } - catch (error) { - log("Read Aloud spotlight failed: " + error); - } - // Follow the reading position: scroll/turn to the current segment. `navigateToSelector` moves the view to a raw - // selector; the reader's own read-aloud follow (read-aloud.ts) uses it the same way. (The built-in spotlight - // navigate instead passes the selector to `navigate`, which only acts on `{ position }` / `{ annotationID }` - // locations and is therefore a no-op — that's why the view never moved.) Options mirror desktop: `ifNeeded` skips - // the move when already visible, `block: 'center'` keeps the read text centered. - if (position) { - try { - window._view._view.navigateToSelector(position, { ifNeeded: true, block: 'center', behavior: 'smooth' }); - } - catch (error) { - log("Read Aloud follow navigate failed: " + error); - } - } + window._view.setReadAloudSpotlight(position); }; // Notify when iframe is loaded