Skip to content

Commit 2839546

Browse files
Merge pull request #2154 from syncfusion-content/ES-1006970-AccessibleDev
1006970: Resolved iPad Issues
2 parents fc8cd17 + 31e26f0 commit 2839546

1 file changed

Lines changed: 288 additions & 7 deletions

File tree

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

Lines changed: 288 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,144 @@ 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+
const available = speechSynthesis.getVoices();
145+
let voice = null;
146+
voice = available.find(v => v.default) || available[0];
147+
if (voice) {
148+
utterThis.voice = voice;
149+
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
150+
}
151+
152+
utterThis.pitch = 1;
153+
utterThis.rate = 1;
154+
155+
// Safari sometimes needs a microtask delay after cancel before speak
156+
setTimeout(() => synth.speak(utterThis), 0);
157+
}
158+
159+
async function initUi() {
160+
await voicesReady;
161+
// Populate voices now that controls exist
162+
populateVoiceList();
163+
if (speechSynthesis.onvoiceschanged !== undefined) {
164+
speechSynthesis.onvoiceschanged = populateVoiceList;
165+
}
166+
return true;
167+
}
168+
169+
// Initialize when DOM is ready; also handle Blazor re-renders
170+
document.addEventListener('DOMContentLoaded', async () => {
171+
// Set up iOS unlock listeners early
172+
ensureTtsUnlocked();
173+
if (await initUi()) return;
174+
const obs = new MutationObserver(async () => {
175+
if (await initUi()) obs.disconnect();
176+
});
177+
obs.observe(document.body, { childList: true, subtree: true });
178+
});
179+
180+
// Expose a helper to manually unlock from .NET or UI (tap/click)
181+
window.unlockTtsForIOS = () => ensureTtsUnlocked();
182+
45183
// Initialize PDF accessibility features and observe page changes
46184
function initPdfAccessibility() {
47185
const viewerInfo = getViewerInfo();
@@ -61,6 +199,7 @@ function initPdfAccessibility() {
61199
});
62200
mutationObserver.observe(viewerInfo.container, { childList: true, subtree: true });
63201
}
202+
64203
// Get viewer container and ID
65204
function getViewerInfo() {
66205
const container = document.querySelector('.e-pv-viewer-container');
@@ -119,13 +258,14 @@ function wirePage(div) {
119258
div.addEventListener('click', () => focusPageDiv(div));
120259
div.setAttribute('data-a11y-init', 'true');
121260
}
261+
122262
// Reader the selected text aloud - Mircosoft Reader
123263
function readAloudText(text) {
124264
window.speechSynthesis.cancel();
125-
const utterance = new SpeechSynthesisUtterance(text);
126-
window.speechSynthesis.speak(utterance);
265+
speakFromControls(text);
127266
}
128-
// Cancel speech - Mircosoft Reader
267+
268+
// Cancel speech and remove highlights - Mircosoft Reader
129269
function cancelReading() {
130270
if (window.speechSynthesis?.speaking) {
131271
window.speechSynthesis.cancel();
@@ -209,9 +349,148 @@ Use the browser’s Windows Speech Synthesis API (speechSynthesis) to implement
209349

210350
{% tabs %}
211351
{% highlight js tabtitle="accessibility.js" %}
352+
const synth = window.speechSynthesis;
353+
354+
let voices = [];
355+
356+
const voicesReady = new Promise((resolve) => {
357+
const tryGet = () => {
358+
const voice = synth.getVoices();
359+
if (voice && voice.length) {
360+
voices = voice;
361+
resolve(voice);
362+
return true;
363+
}
364+
return false;
365+
};
366+
if (tryGet()) return;
367+
const onVoices = () => {
368+
if (tryGet()) {
369+
synth.removeEventListener('voiceschanged', onVoices);
370+
}
371+
};
372+
synth.addEventListener('voiceschanged', onVoices);
373+
// Fallback polling for browsers that don't fire voiceschanged reliably
374+
let tries = 0;
375+
const poll = setInterval(() => {
376+
if (tryGet() || ++tries > 30) {
377+
clearInterval(poll);
378+
voices = synth.getVoices() || [];
379+
synth.removeEventListener('voiceschanged', onVoices);
380+
resolve(voices);
381+
}
382+
}, 100);
383+
});
384+
385+
// iOS/iPadOS Safari requires a user gesture before speech works reliably.
386+
// This one-time unlock speaks a silent utterance on first tap/click/keydown.
387+
let __ttsUnlocked = false;
388+
let __unlockPromise = null;
389+
function ensureTtsUnlocked() {
390+
if (__ttsUnlocked) return Promise.resolve();
391+
if (__unlockPromise) return __unlockPromise;
392+
__unlockPromise = new Promise((resolve) => {
393+
const cleanup = () => {
394+
['click', 'touchstart', 'keydown'].forEach(evt => document.removeEventListener(evt, onEvent, true));
395+
};
396+
const onEvent = () => {
397+
try {
398+
const u = new SpeechSynthesisUtterance(''); // silent token
399+
u.volume = 0;
400+
u.rate = 1;
401+
u.onend = () => {
402+
__ttsUnlocked = true;
403+
cleanup();
404+
resolve();
405+
};
406+
// Queue in a macrotask to avoid race with gesture handling
407+
setTimeout(() => synth.speak(u), 0);
408+
} catch (_) {
409+
__ttsUnlocked = true;
410+
cleanup();
411+
resolve();
412+
}
413+
};
414+
['click', 'touchstart', 'keydown'].forEach(evt => document.addEventListener(evt, onEvent, true));
415+
});
416+
return __unlockPromise;
417+
}
418+
419+
function populateVoiceList() {
420+
voices = synth.getVoices().sort(function (a, b) {
421+
const aname = a.name.toUpperCase();
422+
const bname = b.name.toUpperCase();
423+
424+
if (aname < bname) {
425+
return -1;
426+
} else if (aname == bname) {
427+
return 0;
428+
} else {
429+
return +1;
430+
}
431+
});
432+
}
433+
434+
async function speakFromControls(input) {
435+
await voicesReady;
436+
437+
const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
438+
if (!t) return;
439+
440+
// Cancel any current speech to avoid overlaps/refresh quirks
441+
if (synth.speaking) {
442+
synth.cancel();
443+
}
444+
445+
const utterThis = new SpeechSynthesisUtterance(t);
446+
447+
utterThis.onend = function () {
448+
console.log("SpeechSynthesisUtterance.onend");
449+
};
450+
451+
const available = speechSynthesis.getVoices();
452+
let voice = null;
453+
voice = available.find(v => v.default) || available[0];
454+
if (voice) {
455+
utterThis.voice = voice;
456+
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
457+
}
458+
459+
utterThis.pitch = 1;
460+
utterThis.rate = 1;
461+
462+
// Safari sometimes needs a microtask delay after cancel before speak
463+
setTimeout(() => synth.speak(utterThis), 0);
464+
}
465+
466+
async function initUi() {
467+
await voicesReady;
468+
// Populate voices now that controls exist
469+
populateVoiceList();
470+
if (speechSynthesis.onvoiceschanged !== undefined) {
471+
speechSynthesis.onvoiceschanged = populateVoiceList;
472+
}
473+
return true;
474+
}
475+
476+
// Initialize when DOM is ready; also handle Blazor re-renders
477+
document.addEventListener('DOMContentLoaded', async () => {
478+
// Set up iOS unlock listeners early
479+
ensureTtsUnlocked();
480+
if (await initUi()) return;
481+
const obs = new MutationObserver(async () => {
482+
if (await initUi()) obs.disconnect();
483+
});
484+
obs.observe(document.body, { childList: true, subtree: true });
485+
});
486+
487+
// Expose a helper to manually unlock from .NET or UI (tap/click)
488+
window.unlockTtsForIOS = () => ensureTtsUnlocked();
489+
212490
// Register .NET object for interop
213491
function registerDotNetObject(dotNetObj) {
214-
window.myDotNetObj = dotNetObj;
492+
window.myDotNetObj = dotNetObj;
493+
ensureTtsUnlocked();
215494
}
216495
// Read selected text and highlight
217496
function readSelectedText(args, zoomLevel, muteVoice) {
@@ -226,7 +505,7 @@ function readSelectedText(args, zoomLevel, muteVoice) {
226505
});
227506
if (muteVoice) return;
228507
requestAnimationFrame(() => {
229-
speakText(text, clearAllHighlights);
508+
speakFromControls(text);
230509
});
231510
}
232511
// Read a line from page and notify .NET
@@ -258,7 +537,7 @@ function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
258537
if (currentLineSpans && !muteVoice) {
259538
const lineText = currentLineSpans.map(s => s.textContent).join(' ');
260539
requestAnimationFrame(() => {
261-
speakText(lineText, clearAllHighlights);
540+
speakFromControls(lineText);
262541
});
263542
}
264543
}
@@ -332,6 +611,7 @@ function getLinesFromPage(pageIndex) {
332611
})
333612
);
334613
}
614+
335615
// Speak text with optional target voiceUri; on end, invoke callbacks and reset UI
336616
function speakText(text, onEnd) {
337617
if (!text || !text.trim()) text = "Warning. No readable text found.";
@@ -451,12 +731,13 @@ function clearAllHighlights() {
451731
clearLineHighlight();
452732
clearSelectedHighlights();
453733
}
734+
454735
// Pause or resume speech synthesis
455736
function readAloudMute(isPaused) {
456737
const speechSynth = window.speechSynthesis;
457738
isPaused ? speechSynth.resume() : speechSynth.pause();
458739
}
459-
// Cancel speech and remove highlights
740+
// Cancel speech and remove highlights - Mircosoft Reader
460741
function cancelReading() {
461742
if (window.speechSynthesis?.speaking) {
462743
window.speechSynthesis.cancel();

0 commit comments

Comments
 (0)