Skip to content

Commit 87cbd1c

Browse files
1006970: Resolved iPad Issues
1 parent 57f1c62 commit 87cbd1c

1 file changed

Lines changed: 298 additions & 7 deletions

File tree

Document-Processing/PDF/PDF-Viewer/blazor/accessible-pdf-reading.md

Lines changed: 298 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,148 @@ Use Microsoft Edge’s built-in Read Aloud to listen to the PDF content.
4242
{% tabs %}
4343
{% highlight js tabtitle="accessibility.js" %}
4444

45+
const synth = window.speechSynthesis;
46+
47+
let voices = [];
48+
49+
const voicesReady = new Promise((resolve) => {
50+
const tryGet = () => {
51+
const voice = synth.getVoices();
52+
if (voice && voice.length) {
53+
voices = voice;
54+
resolve(voice);
55+
return true;
56+
}
57+
return false;
58+
};
59+
if (tryGet()) return;
60+
const onVoices = () => {
61+
if (tryGet()) {
62+
synth.removeEventListener('voiceschanged', onVoices);
63+
}
64+
};
65+
synth.addEventListener('voiceschanged', onVoices);
66+
// Fallback polling for browsers that don't fire voiceschanged reliably
67+
let tries = 0;
68+
const poll = setInterval(() => {
69+
if (tryGet() || ++tries > 30) {
70+
clearInterval(poll);
71+
voices = synth.getVoices() || [];
72+
synth.removeEventListener('voiceschanged', onVoices);
73+
resolve(voices);
74+
}
75+
}, 100);
76+
});
77+
78+
// iOS/iPadOS Safari requires a user gesture before speech works reliably.
79+
// This one-time unlock speaks a silent utterance on first tap/click/keydown.
80+
let __ttsUnlocked = false;
81+
let __unlockPromise = null;
82+
function ensureTtsUnlocked() {
83+
if (__ttsUnlocked) return Promise.resolve();
84+
if (__unlockPromise) return __unlockPromise;
85+
__unlockPromise = new Promise((resolve) => {
86+
const cleanup = () => {
87+
['click', 'touchstart', 'keydown'].forEach(evt => document.removeEventListener(evt, onEvent, true));
88+
};
89+
const onEvent = () => {
90+
try {
91+
const u = new SpeechSynthesisUtterance(''); // silent token
92+
u.volume = 0;
93+
u.rate = 1;
94+
u.onend = () => {
95+
__ttsUnlocked = true;
96+
cleanup();
97+
resolve();
98+
};
99+
// Queue in a macrotask to avoid race with gesture handling
100+
setTimeout(() => synth.speak(u), 0);
101+
} catch (_) {
102+
__ttsUnlocked = true;
103+
cleanup();
104+
resolve();
105+
}
106+
};
107+
['click', 'touchstart', 'keydown'].forEach(evt => document.addEventListener(evt, onEvent, true));
108+
});
109+
return __unlockPromise;
110+
}
111+
112+
function populateVoiceList() {
113+
voices = synth.getVoices().sort(function (a, b) {
114+
const aname = a.name.toUpperCase();
115+
const bname = b.name.toUpperCase();
116+
117+
if (aname < bname) {
118+
return -1;
119+
} else if (aname == bname) {
120+
return 0;
121+
} else {
122+
return +1;
123+
}
124+
});
125+
}
126+
127+
async function speakFromControls(input) {
128+
await voicesReady;
129+
130+
const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
131+
if (!t) return;
132+
133+
// Cancel any current speech to avoid overlaps/refresh quirks
134+
if (synth.speaking) {
135+
synth.cancel();
136+
}
137+
138+
const utterThis = new SpeechSynthesisUtterance(t);
139+
140+
utterThis.onend = function () {
141+
console.log("SpeechSynthesisUtterance.onend");
142+
};
143+
144+
utterThis.onerror = function (e) {
145+
console.error("SpeechSynthesisUtterance.onerror", e);
146+
};
147+
148+
const available = speechSynthesis.getVoices();
149+
let voice = null;
150+
voice = available.find(v => v.default) || available[0];
151+
if (voice) {
152+
utterThis.voice = voice;
153+
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
154+
}
155+
156+
utterThis.pitch = 1;
157+
utterThis.rate = 1;
158+
159+
// Safari sometimes needs a microtask delay after cancel before speak
160+
setTimeout(() => synth.speak(utterThis), 0);
161+
}
162+
163+
async function initUi() {
164+
await voicesReady;
165+
// Populate voices now that controls exist
166+
populateVoiceList();
167+
if (speechSynthesis.onvoiceschanged !== undefined) {
168+
speechSynthesis.onvoiceschanged = populateVoiceList;
169+
}
170+
return true;
171+
}
172+
173+
// Initialize when DOM is ready; also handle Blazor re-renders
174+
document.addEventListener('DOMContentLoaded', async () => {
175+
// Set up iOS unlock listeners early
176+
ensureTtsUnlocked();
177+
if (await initUi()) return;
178+
const obs = new MutationObserver(async () => {
179+
if (await initUi()) obs.disconnect();
180+
});
181+
obs.observe(document.body, { childList: true, subtree: true });
182+
});
183+
184+
// Expose a helper to manually unlock from .NET or UI (tap/click)
185+
window.unlockTtsForIOS = () => ensureTtsUnlocked();
186+
45187
// Initialize PDF accessibility features and observe page changes
46188
function initPdfAccessibility() {
47189
const viewerInfo = getViewerInfo();
@@ -61,6 +203,7 @@ function initPdfAccessibility() {
61203
});
62204
mutationObserver.observe(viewerInfo.container, { childList: true, subtree: true });
63205
}
206+
64207
// Get viewer container and ID
65208
function getViewerInfo() {
66209
const container = document.querySelector('.e-pv-viewer-container');
@@ -119,13 +262,14 @@ function wirePage(div) {
119262
div.addEventListener('click', () => focusPageDiv(div));
120263
div.setAttribute('data-a11y-init', 'true');
121264
}
265+
122266
// Reader the selected text aloud - Mircosoft Reader
123267
function readAloudText(text) {
124268
window.speechSynthesis.cancel();
125-
const utterance = new SpeechSynthesisUtterance(text);
126-
window.speechSynthesis.speak(utterance);
269+
speakFromControls(text);
127270
}
128-
// Cancel speech - Mircosoft Reader
271+
272+
// Cancel speech and remove highlights - Mircosoft Reader
129273
function cancelReading() {
130274
if (window.speechSynthesis?.speaking) {
131275
window.speechSynthesis.cancel();
@@ -209,9 +353,154 @@ Use the browser’s Windows Speech Synthesis API (speechSynthesis) to implement
209353

210354
{% tabs %}
211355
{% highlight js tabtitle="accessibility.js" %}
356+
const synth = window.speechSynthesis;
357+
358+
let voices = [];
359+
360+
const voicesReady = new Promise((resolve) => {
361+
const tryGet = () => {
362+
const voice = synth.getVoices();
363+
if (voice && voice.length) {
364+
voices = voice;
365+
resolve(voice);
366+
return true;
367+
}
368+
return false;
369+
};
370+
if (tryGet()) return;
371+
const onVoices = () => {
372+
if (tryGet()) {
373+
synth.removeEventListener('voiceschanged', onVoices);
374+
}
375+
};
376+
synth.addEventListener('voiceschanged', onVoices);
377+
// Fallback polling for browsers that don't fire voiceschanged reliably
378+
let tries = 0;
379+
const poll = setInterval(() => {
380+
if (tryGet() || ++tries > 30) {
381+
clearInterval(poll);
382+
voices = synth.getVoices() || [];
383+
synth.removeEventListener('voiceschanged', onVoices);
384+
resolve(voices);
385+
}
386+
}, 100);
387+
});
388+
389+
// iOS/iPadOS Safari requires a user gesture before speech works reliably.
390+
// This one-time unlock speaks a silent utterance on first tap/click/keydown.
391+
let __ttsUnlocked = false;
392+
let __unlockPromise = null;
393+
function ensureTtsUnlocked() {
394+
if (__ttsUnlocked) return Promise.resolve();
395+
if (__unlockPromise) return __unlockPromise;
396+
__unlockPromise = new Promise((resolve) => {
397+
const cleanup = () => {
398+
['click', 'touchstart', 'keydown'].forEach(evt => document.removeEventListener(evt, onEvent, true));
399+
};
400+
const onEvent = () => {
401+
try {
402+
const u = new SpeechSynthesisUtterance(''); // silent token
403+
u.volume = 0;
404+
u.rate = 1;
405+
u.onend = () => {
406+
__ttsUnlocked = true;
407+
cleanup();
408+
resolve();
409+
};
410+
// Queue in a macrotask to avoid race with gesture handling
411+
setTimeout(() => synth.speak(u), 0);
412+
} catch (_) {
413+
__ttsUnlocked = true;
414+
cleanup();
415+
resolve();
416+
}
417+
};
418+
['click', 'touchstart', 'keydown'].forEach(evt => document.addEventListener(evt, onEvent, true));
419+
});
420+
return __unlockPromise;
421+
}
422+
423+
function populateVoiceList() {
424+
voices = synth.getVoices().sort(function (a, b) {
425+
const aname = a.name.toUpperCase();
426+
const bname = b.name.toUpperCase();
427+
428+
if (aname < bname) {
429+
return -1;
430+
} else if (aname == bname) {
431+
return 0;
432+
} else {
433+
return +1;
434+
}
435+
});
436+
}
437+
438+
async function speakFromControls(input) {
439+
// iOS/iPadOS: require user-gesture unlock and stable voice list
440+
await ensureTtsUnlocked().catch(() => { });
441+
await voicesReady;
442+
443+
const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
444+
if (!t) return;
445+
446+
// Cancel any current speech to avoid overlaps/refresh quirks
447+
if (synth.speaking) {
448+
synth.cancel();
449+
}
450+
451+
const utterThis = new SpeechSynthesisUtterance(t);
452+
453+
utterThis.onend = function () {
454+
console.log("SpeechSynthesisUtterance.onend");
455+
};
456+
457+
utterThis.onerror = function (e) {
458+
console.error("SpeechSynthesisUtterance.onerror", e);
459+
};
460+
461+
const available = speechSynthesis.getVoices();
462+
let voice = null;
463+
voice = available.find(v => v.default) || available[0];
464+
if (voice) {
465+
utterThis.voice = voice;
466+
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
467+
}
468+
469+
utterThis.pitch = 1;
470+
utterThis.rate = 1;
471+
472+
// Safari sometimes needs a microtask delay after cancel before speak
473+
setTimeout(() => synth.speak(utterThis), 0);
474+
}
475+
476+
async function initUi() {
477+
await voicesReady;
478+
// Populate voices now that controls exist
479+
populateVoiceList();
480+
if (speechSynthesis.onvoiceschanged !== undefined) {
481+
speechSynthesis.onvoiceschanged = populateVoiceList;
482+
}
483+
return true;
484+
}
485+
486+
// Initialize when DOM is ready; also handle Blazor re-renders
487+
document.addEventListener('DOMContentLoaded', async () => {
488+
// Set up iOS unlock listeners early
489+
ensureTtsUnlocked();
490+
if (await initUi()) return;
491+
const obs = new MutationObserver(async () => {
492+
if (await initUi()) obs.disconnect();
493+
});
494+
obs.observe(document.body, { childList: true, subtree: true });
495+
});
496+
497+
// Expose a helper to manually unlock from .NET or UI (tap/click)
498+
window.unlockTtsForIOS = () => ensureTtsUnlocked();
499+
212500
// Register .NET object for interop
213501
function registerDotNetObject(dotNetObj) {
214-
window.myDotNetObj = dotNetObj;
502+
window.myDotNetObj = dotNetObj;
503+
ensureTtsUnlocked();
215504
}
216505
// Read selected text and highlight
217506
function readSelectedText(args, zoomLevel, muteVoice) {
@@ -226,7 +515,7 @@ function readSelectedText(args, zoomLevel, muteVoice) {
226515
});
227516
if (muteVoice) return;
228517
requestAnimationFrame(() => {
229-
speakText(text, clearAllHighlights);
518+
speakFromControls(text);
230519
});
231520
}
232521
// Read a line from page and notify .NET
@@ -258,7 +547,7 @@ function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
258547
if (currentLineSpans && !muteVoice) {
259548
const lineText = currentLineSpans.map(s => s.textContent).join(' ');
260549
requestAnimationFrame(() => {
261-
speakText(lineText, clearAllHighlights);
550+
speakFromControls(lineText);
262551
});
263552
}
264553
}
@@ -332,6 +621,7 @@ function getLinesFromPage(pageIndex) {
332621
})
333622
);
334623
}
624+
335625
// Speak text with optional target voiceUri; on end, invoke callbacks and reset UI
336626
function speakText(text, onEnd) {
337627
if (!text || !text.trim()) text = "Warning. No readable text found.";
@@ -451,12 +741,13 @@ function clearAllHighlights() {
451741
clearLineHighlight();
452742
clearSelectedHighlights();
453743
}
744+
454745
// Pause or resume speech synthesis
455746
function readAloudMute(isPaused) {
456747
const speechSynth = window.speechSynthesis;
457748
isPaused ? speechSynth.resume() : speechSynth.pause();
458749
}
459-
// Cancel speech and remove highlights
750+
// Cancel speech and remove highlights - Mircosoft Reader
460751
function cancelReading() {
461752
if (window.speechSynthesis?.speaking) {
462753
window.speechSynthesis.cancel();

0 commit comments

Comments
 (0)