diff --git a/src/common/components/sidebar/outline-view.js b/src/common/components/sidebar/outline-view.js index b7f97594..9291c9bc 100644 --- a/src/common/components/sidebar/outline-view.js +++ b/src/common/components/sidebar/outline-view.js @@ -102,7 +102,7 @@ function Item({ item, id, children, onOpenLink, onUpdate, onSelect }) { let { expanded, active } = item; return ( -
  • +
  • x.active); let currentItem = list[currentIndex]; @@ -239,6 +240,7 @@ function OutlineView({ outline, currentOutlinePath, onNavigate, onOpenLink, onUp ); } + let active = flatten(outline || []).findIndex(item => item.active); return (
    {outline === null ?
    : renderItems(outline)}
    diff --git a/src/common/components/sidebar/sidebar.js b/src/common/components/sidebar/sidebar.js index 6776bf93..ed036bf5 100644 --- a/src/common/components/sidebar/sidebar.js +++ b/src/common/components/sidebar/sidebar.js @@ -105,7 +105,7 @@ function Sidebar(props) { } return ( -
    +
    {props.type === 'pdf' && @@ -166,7 +166,7 @@ function Sidebar(props) {
    {props.annotationsView}
    -
    +
    {props.outlineView}
    diff --git a/src/common/components/toolbar.js b/src/common/components/toolbar.js index 76cbcff0..dede6492 100644 --- a/src/common/components/toolbar.js +++ b/src/common/components/toolbar.js @@ -72,7 +72,7 @@ function Toolbar(props) { } return ( -
    +
    )} diff --git a/src/common/components/view-popup/find-popup.js b/src/common/components/view-popup/find-popup.js index a6acb08c..9d1f8554 100644 --- a/src/common/components/view-popup/find-popup.js +++ b/src/common/components/view-popup/find-popup.js @@ -107,7 +107,7 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati } return ( -
    +
    ) + if (target?.nodeType === Node.TEXT_NODE) { + target = target.parentNode; + } + if (!target) return; + let doc = target.ownerDocument; + // Make it temporarily focusable + target.setAttribute("tabindex", "-1"); + target.classList.add("a11y-cursor-target"); + target.focus({ preventScroll: true }); + // If focus didn't take, remove tabindex and stop + if (doc.activeElement != target) { + target.removeAttribute("tabindex"); + return; + } + function blurHandler() { + target.removeAttribute("tabindex"); + target.classList.remove("a11y-cursor-target"); + target.removeEventListener("blur", blurHandler); + } + // Remove focus when element looses focus + target.addEventListener("blur", blurHandler); + // Blur the target on any keypress so that one can still scroll content with + // arrowUp/Down. Otherwise, all keydown events land on the target and + // nothing happens + function keydownHandler() { + target.blur(); + target.removeEventListener("keydown", keydownHandler); + } + target.addEventListener("keydown", keydownHandler); +} diff --git a/src/common/reader.js b/src/common/reader.js index 9abd1086..3468e834 100644 --- a/src/common/reader.js +++ b/src/common/reader.js @@ -700,23 +700,27 @@ class Reader { this._onTextSelectionAnnotationModeChange(mode); } - // Announce the index of current search result to screen readers - setA11ySearchResultMessage(primaryView) { - let result = (primaryView ? this._state.primaryViewFindState : this._state.secondaryViewFindState).result; - if (!result) return; - let searchIndex = `${this._getString("pdfReader.searchResultIndex")}: ${result.index + 1}`; - let totalResults = `${this._getString("pdfReader.searchResultTotal")}: ${result.total}`; - this.setA11yMessage(`${searchIndex}. ${totalResults}`); - } + // Announce info about current search result to screen readers. + // FindState is updated multiple times while navigating between results + // so debounce is used to fire only after the last update. + a11yAnnounceSearchMessage = debounce((findStateResult) => { + if (!findStateResult) return; + let { index, total, currentPageLabel, currentSnippet } = findStateResult; + if (total == 0) { + this.setA11yMessage(this._getString("pdfReader.phraseNotFound")); + return; + } + let searchIndex = `${this._getString("pdfReader.searchResultIndex")}: ${index + 1}.`; + let totalResults = `${this._getString("pdfReader.searchResultTotal")}: ${total}.`; + let page = currentPageLabel ? `${this._getString("pdfReader.page")}: ${currentPageLabel}.` : ""; + this.setA11yMessage(`${searchIndex} ${totalResults} ${page} ${currentSnippet || ""}`); + }, 100); findNext(primary) { if (primary === undefined) { primary = this._lastViewPrimary; } (primary ? this._primaryView : this._secondaryView).findNext(); - setTimeout(() => { - this.setA11ySearchResultMessage(primary); - }); } findPrevious(primary) { @@ -724,9 +728,6 @@ class Reader { primary = this._lastViewPrimary; } (primary ? this._primaryView : this._secondaryView).findPrevious(); - setTimeout(() => { - this.setA11ySearchResultMessage(primary); - }); } toggleEPUBAppearancePopup({ open }) { @@ -885,6 +886,7 @@ class Reader { let onSetFindState = (params) => { this._updateState({ [primary ? 'primaryViewFindState' : 'secondaryViewFindState']: params }); + this.a11yAnnounceSearchMessage(params.result); }; let onSelectAnnotations = (ids, triggeringEvent) => { @@ -923,6 +925,8 @@ class Reader { this.setA11yMessage(annotationContent); } + let getLocalizedString = (name) => this._getString(name); + let data; if (this._type === 'pdf') { data = this._data; @@ -971,7 +975,8 @@ class Reader { onTabOut, onKeyDown, onKeyUp, - onFocusAnnotation + onFocusAnnotation, + getLocalizedString }; if (this._type === 'pdf') { diff --git a/src/common/types.ts b/src/common/types.ts index f02ee8a3..0821f3ec 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -163,6 +163,9 @@ export type FindState = { // Mobile app lists all results in a popup snippets: string[], annotation?: NewAnnotation + // Used for a11y notifications + currentSnippet: string, + currentPageLabel: string | null } | null; }; diff --git a/src/dom/common/dom-view.tsx b/src/dom/common/dom-view.tsx index fe225675..1f75b9e2 100644 --- a/src/dom/common/dom-view.tsx +++ b/src/dom/common/dom-view.tsx @@ -43,7 +43,8 @@ import { getCodeCombination, getKeyCombination, isMac, - isSafari + isSafari, + placeA11yVirtualCursor } from "../../common/lib/utilities"; import { closestElement, @@ -144,6 +145,8 @@ abstract class DOMView { protected _lastKeyboardFocusedAnnotationID: string | null = null; + protected _a11yVirtualCursorTarget: Node | null; + scale = 1; protected constructor(options: DOMViewOptions) { @@ -164,6 +167,7 @@ abstract class DOMView { onUpdate: () => this._updateViewStats(), onNavigate: location => this.navigate(location, { skipHistory: true, behavior: 'auto' }), }); + this._a11yVirtualCursorTarget = null; this._iframe = document.createElement('iframe'); this._iframe.sandbox.add('allow-same-origin', 'allow-modals'); @@ -705,6 +709,7 @@ abstract class DOMView { let annotationsStyle = this._iframeDocument.createElement('style'); annotationsStyle.innerHTML = annotationsCSS; this._annotationShadowRoot.append(annotationsStyle); + this._annotationShadowRoot.addEventListener("focusin", this._handleAnnotationFocus.bind(this)); this._iframeDocument.documentElement.classList.toggle('is-safari', isSafari); @@ -1348,6 +1353,9 @@ abstract class DOMView { this._options.onSetOverlayPopup(); + // If we marked a node as future focus target for screen readers, clear it to not interfere with focus + this._a11yVirtualCursorTarget = null; + // Create note annotation on pointer down event, if note tool is active. // The note tool will be automatically deactivated in reader.js, // because this is what we do in PDF reader @@ -1466,6 +1474,11 @@ abstract class DOMView { this._renderAnnotations(); this._repositionPopups(); }); + // If there exists a focused node for screen readers, make sure it gets blurred + let cursorTarget = this._iframeDocument.querySelector(".a11y-cursor-target"); + if (cursorTarget) { + (cursorTarget as HTMLElement).blur(); + } } protected _handleScrollCapture(event: Event) { @@ -1532,6 +1545,18 @@ abstract class DOMView { private _handleFocus() { this._options.onFocus(); + + // Help screen readers understand where to place virtual cursor + placeA11yVirtualCursor(this._a11yVirtualCursorTarget); + this._a11yVirtualCursorTarget = null; + } + + private _handleAnnotationFocus(event: Event) { + let annotationID = (event.target as HTMLElement).dataset.annotationId; + let annotation = annotationID ? this._annotations.find(ann => ann.id == annotationID) : null; + if (annotation) { + this._options.onFocusAnnotation(annotation); + } } private _preventNextClickEvent() { @@ -1762,6 +1787,8 @@ export type DOMViewOptions = { onKeyUp: (event: KeyboardEvent) => void; onKeyDown: (event: KeyboardEvent) => void; onEPUBEncrypted: () => void; + onFocusAnnotation: (annotation: WADMAnnotation) => void; + getLocalizedString: (name: string) => string; data: Data & { buf?: Uint8Array, url?: string diff --git a/src/dom/epub/defines.ts b/src/dom/epub/defines.ts index 8aedefa5..4456b130 100644 --- a/src/dom/epub/defines.ts +++ b/src/dom/epub/defines.ts @@ -28,3 +28,5 @@ export const DEFAULT_EPUB_APPEARANCE: EPUBAppearance = Object.freeze({ pageWidth: PageWidth.Normal, useOriginalFont: false, }); + +export const A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH = 100; diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts index 43d4e946..edb6845c 100644 --- a/src/dom/epub/epub-view.ts +++ b/src/dom/epub/epub-view.ts @@ -17,6 +17,7 @@ import Epub, { NavItem, } from "epubjs"; import { + getStartElement, moveRangeEndsIntoTextNodes, PersistentRange, splitRangeToTextNodes @@ -50,12 +51,13 @@ import { PaginatedFlow, ScrolledFlow } from "./flow"; -import { DEFAULT_EPUB_APPEARANCE, RTL_SCRIPTS } from "./defines"; +import { DEFAULT_EPUB_APPEARANCE, RTL_SCRIPTS, A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH } from "./defines"; import { parseAnnotationsFromKOReaderMetadata, koReaderAnnotationToRange } from "./lib/koreader"; import { ANNOTATION_COLORS } from "../../common/defines"; import { calibreAnnotationToRange, parseAnnotationsFromCalibreMetadata } from "./lib/calibre"; import LRUCacheMap from "../common/lib/lru-cache-map"; import { mode } from "../common/lib/collection"; +import { debounce } from '../../common/lib/debounce'; class EPUBView extends DOMView { protected _find: EPUBFindProcessor | null = null; @@ -185,6 +187,7 @@ class EPUBView extends DOMView { this._sectionsContainer.hidden = false; this.pageMapping = this._initPageMapping(viewState.savedPageMapping); this._initOutline(); + this._addAriaNavigationLandmarks(); // Validate viewState and its properties // Also make sure this doesn't trigger _updateViewState @@ -390,6 +393,25 @@ class EPUBView extends DOMView { } } + // Add landmarks with page labels for screen reader navigation + private async _addAriaNavigationLandmarks() { + for (let key of this.pageMapping.ranges()) { + let node = key.startContainer; + let containingElement = closestElement(node); + + if (!containingElement) continue; + + // This is semantically not correct, as we are assigning + // navigation role to

    and nodes but this is the + // best solution to avoid adding nodes into the DOM, which + // will break CFIs. + containingElement.setAttribute("role", "navigation"); + let label = this.pageMapping.getPageLabel(key.toRange()); + let localizedLabel = `${this._options.getLocalizedString("pdfReader.page")}: ${label}`; + containingElement.setAttribute('aria-label', localizedLabel); + } + } + override toSelector(range: Range): FragmentSelector | null { range = moveRangeEndsIntoTextNodes(range); let cfi = this.getCFI(range); @@ -859,6 +881,7 @@ class EPUBView extends DOMView { outlinePath: Date.now() - this._lastNavigationTime > 1500 ? this._getOutlinePath() : undefined, }; this._options.onChangeViewStats(viewStats); + this.a11yWillPlaceVirtCursorOnTop(); } protected override _handleViewUpdate() { @@ -1053,9 +1076,15 @@ class EPUBView extends DOMView { annotation: ( result.range && this._getAnnotationFromRange(result.range.toRange(), 'highlight') - ) ?? undefined + ) ?? undefined, + currentPageLabel: result.range ? this.pageMapping.getPageLabel(result.range.toRange()) : null, + currentSnippet: result.snippets[result.index] } }); + if (result.range) { + // Record the result that sceen readers should focus on after search popup is closed + this._a11yVirtualCursorTarget = getStartElement(result.range); + } }, }); let startRange = (this.flow.startRange && new PersistentRange(this.flow.startRange)) ?? undefined; @@ -1178,6 +1207,21 @@ class EPUBView extends DOMView { } } + // Place virtual cursor to the top of the current page. + // Debounce is needed to make sure that the value is set + // when scrolling is finished because it clears the cursor target. + protected a11yWillPlaceVirtCursorOnTop = debounce(() => { + if (!this.flow.startRange) return; + // If the focus is within the document, do nothing to avoid unnecessarily moving + // the cursor to the top of the page if the window is blurred and then re-focused. + if (this._iframeDocument.hasFocus()) return; + // Do not interfere with marking search results as virtual cursor targets + if (this._findState?.active) return; + let node = this.flow.startRange.startContainer; + let containingElement = closestElement(node); + this._a11yVirtualCursorTarget = containingElement; + }, A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH); + protected _setScale(scale: number) { this._keepPosition(() => { this.scale = scale; diff --git a/src/dom/epub/stylesheets/_content.scss b/src/dom/epub/stylesheets/_content.scss index b6421445..ae16e0cb 100644 --- a/src/dom/epub/stylesheets/_content.scss +++ b/src/dom/epub/stylesheets/_content.scss @@ -229,3 +229,7 @@ body.footnote-popup-content { #annotation-overlay { display: contents; } + +.a11y-cursor-target { + outline: none; +} \ No newline at end of file diff --git a/src/dom/snapshot/snapshot-view.ts b/src/dom/snapshot/snapshot-view.ts index 900f83e7..31a2f8d1 100644 --- a/src/dom/snapshot/snapshot-view.ts +++ b/src/dom/snapshot/snapshot-view.ts @@ -32,6 +32,7 @@ import injectCSS from './stylesheets/inject.scss'; import darkReaderJS from '!!raw-loader!darkreader/darkreader'; import { DynamicThemeFix } from "darkreader"; import { isPageRectVisible } from "../common/lib/rect"; +import { debounceUntilScrollFinishes } from "../../common/lib/utilities"; class SnapshotView extends DOMView { protected _find: DefaultFindProcessor | null = null; @@ -315,6 +316,15 @@ class SnapshotView extends DOMView { console.warn('Not a valid snapshot selector', selector); return; } + let elem = getStartElement(range); + if (elem) { + elem.scrollIntoView(options); + // Remember which node was navigated to for screen readers to place + // virtual cursor on it later. Used for navigating between sections in the outline. + debounceUntilScrollFinishes(this._iframeDocument).then(() => { + this._a11yVirtualCursorTarget = elem; + }); + } // Non-element nodes and ranges don't have scrollIntoView(), // so scroll using a temporary element, removed synchronously @@ -417,9 +427,15 @@ class SnapshotView extends DOMView { annotation: ( result.range && this._getAnnotationFromRange(result.range.toRange(), 'highlight') - ) ?? undefined + ) ?? undefined, + currentPageLabel: null, + currentSnippet: result.snippets[result.index] } }); + if (result.range) { + // Record the result that sceen readers should focus on after search popup is closed + this._a11yVirtualCursorTarget = getStartElement(result.range); + } }, }); await this._find.run( diff --git a/src/dom/snapshot/stylesheets/inject.scss b/src/dom/snapshot/stylesheets/inject.scss index da8c5172..09124dff 100644 --- a/src/dom/snapshot/stylesheets/inject.scss +++ b/src/dom/snapshot/stylesheets/inject.scss @@ -1,3 +1,7 @@ ::selection { background-color: var(--selection-color); } + +.a11y-cursor-target { + outline: none; +} \ No newline at end of file diff --git a/src/pdf/pdf-view.js b/src/pdf/pdf-view.js index 7a5df619..28adc0d6 100644 --- a/src/pdf/pdf-view.js +++ b/src/pdf/pdf-view.js @@ -45,13 +45,16 @@ import { isWin, isFirefox, isSafari, - throttle + throttle, + placeA11yVirtualCursor } from '../common/lib/utilities'; +import { debounce } from '../common/lib/debounce'; import { AutoScroll } from './lib/auto-scroll'; import { PDFThumbnails } from './pdf-thumbnails'; import { MIN_IMAGE_ANNOTATION_SIZE, - PDF_NOTE_DIMENSIONS + PDF_NOTE_DIMENSIONS, + A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH } from '../common/defines'; import PDFRenderer from './pdf-renderer'; import { drawAnnotationsOnCanvas } from './lib/render'; @@ -139,6 +142,8 @@ class PDFView { this.initializedPromise = new Promise(resolve => this._resolveInitializedPromise = resolve); this._pageLabelsPromise = new Promise(resolve => this._resolvePageLabelsPromise = resolve); + this._a11yVirtualCursorTarget = null; + let setOptions = () => { if (!this._iframeWindow?.PDFViewerApplicationOptions) { return; @@ -227,10 +232,15 @@ class PDFView { this._onSetSelectionPopup({ ...this._selectionPopup, rect }); } } + // If there exists a focused node for screen readers, make sure it gets blurred + this._iframeWindow.document.querySelector(".a11y-cursor-target")?.blur(); }); this._iframeWindow.addEventListener('focus', (event) => { options.onFocus(); + // Help screen readers understand where to place virtual cursor + placeA11yVirtualCursor(this._a11yVirtualCursorTarget); + this._a11yVirtualCursorTarget = null; }); }); }); @@ -787,6 +797,9 @@ class PDFView { findPrevious: false }); } + // Make sure the state is updated regardless to have last _findState.result value + this._findState = state; + this.a11yWillPlaceVirtCursorOnSearchResult(); } else { this._findState = state; @@ -818,6 +831,32 @@ class PDFView { }); } + + // After the search result is switched to, record which node the + // search result is in to place screen readers' virtual cursor on it. + a11yWillPlaceVirtCursorOnSearchResult = debounce(() => { + // Seemingly no longer works + let searchResult = this._iframeWindow.document.querySelector(".highlight.selected.appended"); + if (!searchResult || !this._findState.result) return; + this._a11yVirtualCursorTarget = searchResult.parentNode; + }, A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH); + + // Record the top of the current page as the element that the virtual cursor + // should land on when focus enters the content. Debounce is needed to + // make sure that the value is set when scrolling is finished because it + // clears the cursor target. + a11yWillPlaceVirtCursorOnTop = debounce(() => { + // If the focus is within the document, do nothing to avoid unnecessarily moving + // the cursor to the top of the page if the window is blurred and then re-focused. + if (this._iframeWindow.document.hasFocus()) return; + // Do not interfere with marking search results as virtual cursor targets + if (this._findState?.active) return; + let { currentPageNumber } = this._iframeWindow.PDFViewerApplication.pdfViewer; + let page = this._iframeWindow.PDFViewerApplication.pdfViewer._pages[currentPageNumber - 1]; + let pageTop = page.div.querySelector(".textLayer span"); + this._a11yVirtualCursorTarget = pageTop; + }, A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH); + setSelectedAnnotationIDs(ids) { this._selectedAnnotationIDs = ids; this._setSelectionRanges(); @@ -1684,6 +1723,8 @@ class PDFView { // Prevents showing focus box after pressing Enter and de-selecting annotation which was select with mouse this._lastFocusedObject = null; + // If we marked a node as future focus target for screen readers, clear it to avoid scrolling to it + this._a11yVirtualCursorTarget = null; if (!event.target.closest('#viewerContainer')) { return; } @@ -2555,6 +2596,7 @@ class PDFView { scrollMode, spreadMode }); + this.a11yWillPlaceVirtCursorOnTop(); } _handleContextMenu(event) {