diff --git a/ts/a11y/complexity/collapse.ts b/ts/a11y/complexity/collapse.ts index 0ba14c3ce..b5eba3bc8 100644 --- a/ts/a11y/complexity/collapse.ts +++ b/ts/a11y/complexity/collapse.ts @@ -505,7 +505,9 @@ export class Collapse { const attributes = node.attributes.getAllAttributes(); for (const name of Object.keys(attributes)) { - if (name.substring(0, 14) === 'data-semantic-') { + if (name.substring(0, 14) === 'data-semantic-' || + name.substring(0, 5) === 'aria-' || + name === 'role') { mrow.attributes.set(name, attributes[name]); delete attributes[name]; } diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index f3b44457a..a20e77938 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -96,14 +96,9 @@ export function ExplorerMathItemMixin>( public explorers: ExplorerPool; /** - * True when a rerendered element should regain the focus + * Semantic id of the rerendered element that should regain the focus. */ - protected refocus: boolean = false; - - /** - * Save explorer id during rerendering. - */ - protected savedId: string = null; + protected refocus: number = null; /** * Add the explorer to the output for this math item @@ -116,10 +111,6 @@ export function ExplorerMathItemMixin>( if (!this.isEscaped && (document.options.enableExplorer || force)) { const node = this.typesetRoot; const mml = toMathML(this.root); - if (this.savedId) { - this.typesetRoot.setAttribute('sre-explorer-id', this.savedId); - this.savedId = null; - } if (!this.explorers) { this.explorers = new ExplorerPool(); } @@ -132,9 +123,12 @@ export function ExplorerMathItemMixin>( * @override */ public rerender(document: ExplorerMathDocument, start: number = STATE.RERENDER) { - this.savedId = this.typesetRoot.getAttribute('sre-explorer-id'); - this.refocus = (hasWindow ? window.document.activeElement === this.typesetRoot : false); if (this.explorers) { + let speech = this.explorers.speech; + if (speech && speech.attached && speech.active) { + const focus = speech.semanticFocus(); + this.refocus = focus ? focus.id : null; + } this.explorers.reattach(); } super.rerender(document, start); @@ -145,11 +139,13 @@ export function ExplorerMathItemMixin>( */ public updateDocument(document: ExplorerMathDocument) { super.updateDocument(document); - this.refocus && this.typesetRoot.focus(); + if (this.explorers?.speech) { + this.explorers.speech.restarted = this.refocus; + } + this.refocus = null; if (this.explorers) { this.explorers.restart(); } - this.refocus = false; } }; diff --git a/ts/a11y/explorer/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts index 949416c55..eaa8c867d 100644 --- a/ts/a11y/explorer/ExplorerPool.ts +++ b/ts/a11y/explorer/ExplorerPool.ts @@ -26,7 +26,7 @@ import {LiveRegion, SpeechRegion, ToolTip, HoverRegion} from './Region.js'; import type { ExplorerMathDocument, ExplorerMathItem } from '../explorer.js'; import {Explorer} from './Explorer.js'; -import * as ke from './KeyExplorer.js'; +import {SpeechExplorer} from './KeyExplorer.js'; import * as me from './MouseExplorer.js'; import {TreeColorer, FlameColorer} from './TreeExplorer.js'; @@ -87,9 +87,9 @@ type ExplorerInit = (doc: ExplorerMathDocument, pool: ExplorerPool, */ let allExplorers: {[options: string]: ExplorerInit} = { speech: (doc: ExplorerMathDocument, pool: ExplorerPool, node: HTMLElement, ...rest: any[]) => { - let explorer = ke.SpeechExplorer.create( + let explorer = SpeechExplorer.create( doc, pool, doc.explorerRegions.speechRegion, node, - doc.explorerRegions.brailleRegion, doc.explorerRegions.magnifier, rest[0], rest[1]) as ke.SpeechExplorer; + doc.explorerRegions.brailleRegion, doc.explorerRegions.magnifier, rest[0], rest[1]) as SpeechExplorer; explorer.sound = true; return explorer; }, @@ -213,7 +213,7 @@ export class ExplorerPool { let keyExplorers = []; for (let key of Object.keys(this.explorers)) { let explorer = this.explorers[key]; - if (explorer instanceof ke.SpeechExplorer) { + if (explorer instanceof SpeechExplorer) { explorer.AddEvents(); explorer.stoppable = false; keyExplorers.unshift(explorer); @@ -253,7 +253,9 @@ export class ExplorerPool { * Restarts explorers after a MathItem is rerendered. */ public restart() { - this._restart.forEach(x => this.explorers[x].Start()); + this._restart.forEach(x => { + this.explorers[x].Start(); + }); this._restart = []; } @@ -275,7 +277,7 @@ export class ExplorerPool { {color: 'red'}, {color: 'black'}, {renderer: this.document.outputJax.name, browser: 'v3'} ); - ((this.explorers['speech'] as ke.SpeechExplorer).region as SpeechRegion).highlighter = + (this.speech.region as SpeechRegion).highlighter = this.secondaryHighlighter; } @@ -295,6 +297,16 @@ export class ExplorerPool { this.highlighter.unhighlight(); } + /** + * Convenience method to return the speech explorer of the pool with the + * correct type. + * + * @return {SpeechExplorer} + */ + public get speech(): SpeechExplorer { + return this.explorers['speech'] as SpeechExplorer; + } + /** * Retrieves color assignment for the document options. * diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 0bb46e1f0..5d7efd003 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -28,11 +28,9 @@ import type { ExplorerMathItem } from '../explorer.js'; import {Explorer, AbstractExplorer} from './Explorer.js'; import {ExplorerPool} from './ExplorerPool.js'; import {MmlNode} from '../../core/MmlTree/MmlNode.js'; -import { honk } from '../SpeechUtil.js'; +import { honk } from '../speech/SpeechUtil.js'; import {Sre} from '../sre.js'; -// import { Walker } from './Walker.js'; - /** * Interface for keyboard explorers. Adds the necessary keyboard events. @@ -73,13 +71,15 @@ export interface KeyExplorer extends Explorer { } -const codeSelector = 'mjx-container'; +/** + * Selectors for walking. + */ const roles = ['tree', 'group', 'treeitem']; const nav = roles.map(x => `[role="${x}"]`).join(','); const prevNav = roles.map(x => `[tabindex="0"][role="${x}"]`).join(','); -function isCodeBlock(el: HTMLElement) { - return el.matches(codeSelector); +function isContainer(el: HTMLElement) { + return el.matches('mjx-container'); } /** @@ -101,17 +101,42 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo public sound: boolean = false; /** - * The attached Sre walker. - * @type {Walker} + * Id of the element focused before the restart. */ - public walker: Sre.walker; + public restarted: number = null; - private eventsAttached: boolean = false; + /** + * Convenience getter for generator pool of the item. + */ + private get generators() { + return this.item?.generatorPool; + } + + /** + * The original tabindex value before explorer was attached. + */ + private oldIndex: number = null; + /** + * The currently focused elements. + */ protected current: HTMLElement = null; + /** + * Flag registering if events of the explorer are attached. + */ + private eventsAttached: boolean = false; + + /** + * Flag to register if the last event was an explorer move. This is important + * so the explorer does not stop (by FocusOut) during a focus shift. + */ private move = false; + /** + * Register the mousedown event. Prevent FocusIn executing twice from click + * and mousedown. + */ private mousedown = false; /** @@ -138,8 +163,17 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo e.preventDefault(); } - public Click(e: MouseEvent) { - const clicked = (e.target as HTMLElement).closest(nav) as HTMLElement; + /** + * Moves on mouse click to the closest clicked element. + * + * @param {MouseEvent} event The mouse click event. + */ + public Click(event: MouseEvent) { + const clicked = (event.target as HTMLElement).closest(nav) as HTMLElement; + if (!this.node.contains(clicked)) { + // In case the mjx-container is in a div, we get the click, although it is outside. + this.mousedown = false; + } if (this.node.contains(clicked)) { const prev = this.node.querySelector(prevNav); if (prev) { @@ -149,16 +183,10 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo if (!this.triggerLinkMouse()) { this.Start() } - e.preventDefault(); + event.preventDefault(); } } - /** - * The original tabindex value before explorer was attached. - * @type {boolean} - */ - private oldIndex: number = null; - /** * @override */ @@ -167,7 +195,7 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo this.mousedown = false; return; } - this.current = this.current || this.node.querySelector('[role="tree"]'); + this.current = this.current || this.node.querySelector('[role="treeitem"]'); this.Start(); event.preventDefault(); } @@ -176,9 +204,12 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo * @override */ public FocusOut(_event: FocusEvent) { + this.generators.CleanUp(this.current); if (!this.move) { this.Stop(); } + this.current?.removeAttribute('tabindex'); + this.node.setAttribute('tabindex', '0'); } /** @@ -213,6 +244,12 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo this.attached = false; } + /** + * Navigate one step to the right on the same level. + * + * @param {HTMLElement} el The current element. + * @return {HTMLElement} The next element. + */ protected nextSibling(el: HTMLElement): HTMLElement { const sib = el.nextElementSibling as HTMLElement; if (sib) { @@ -222,12 +259,18 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo const sibChild = sib.querySelector(nav) as HTMLElement; return sibChild ?? this.nextSibling(sib); } - if (!isCodeBlock(el) && !el.parentElement.matches(nav)) { + if (!isContainer(el) && !el.parentElement.matches(nav)) { return this.nextSibling(el.parentElement); } return null; } + /** + * Navigate one step to the left on the same level. + * + * @param {HTMLElement} el The current element. + * @return {HTMLElement} The next element. + */ protected prevSibling(el: HTMLElement): HTMLElement { const sib = el.previousElementSibling as HTMLElement; if (sib) { @@ -237,7 +280,7 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo const sibChild = sib.querySelector(nav) as HTMLElement; return sibChild ?? this.prevSibling(sib); } - if (!isCodeBlock(el) && !el.parentElement.matches(nav)) { + if (!isContainer(el) && !el.parentElement.matches(nav)) { return this.prevSibling(el.parentElement); } return null; @@ -248,11 +291,62 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo ['ArrowUp', (node: HTMLElement) => node.parentElement.closest(nav)], ['ArrowLeft', this.prevSibling.bind(this)], ['ArrowRight', this.nextSibling.bind(this)], - ['>', (_node: HTMLElement) => { - return null; - }], + ['>', this.nextRules.bind(this)], + ['<', this.nextStyle.bind(this)], + ['x', this.summary.bind(this)], ]); + /** + * Computes the summary for this expression. This is temporary and will be + * replaced by the full speech on focus out. + * + * @param {HTMLElement} node The targeted node. + * @return {HTMLElement} The refocused targeted node. + */ + public summary(node: HTMLElement): HTMLElement { + this.generators.summary(node); + this.refocus(node); + return node; + } + + /** + * Cycles to next speech rule set if possible and recomputes the speech for + * the expression. + * + * @param {HTMLElement} node The targeted node. + * @return {HTMLElement} The refocused targeted node. + */ + public nextRules(node: HTMLElement): HTMLElement { + this.generators.nextRules(node); + this.Speech(); + this.refocus(node); + return node; + } + + /** + * Cycles to next speech style or preference if possible and recomputes the + * speech for the expression. + * + * @param {HTMLElement} node The targeted node. + * @return {HTMLElement} The refocused targeted node. + */ + public nextStyle(node: HTMLElement): HTMLElement { + this.generators.nextStyle(node); + this.Speech(); + this.refocus(node); + return node; + } + + /** + * Refocuses the active elements, mainly to alert screenreaders of changes. + * + * @param {HTMLElement} node The node to refocus on. + */ + private refocus(node: HTMLElement) { + node.blur(); + node.focus(); + } + /** * @override */ @@ -284,30 +378,6 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo honk(); } - private static updatePromise = Promise.resolve(); - - /** - * The Sre speech generator associated with the walker. - * @type {SpeechGenerator} - */ - public speechGenerator: Sre.speechGenerator; - - /** - * The name of the option used to control when this is being shown - * @type {string} - */ - public showRegion: string = 'subtitles'; - - // private init: boolean = false; - - /** - * Flag in case the start method is triggered before the walker is fully - * initialised. I.e., we have to wait for Sre. Then region is re-shown if - * necessary, as otherwise it leads to incorrect stacking. - * @type {boolean} - */ - private restarted: boolean = false; - /** * @constructor * @extends {AbstractKeyExplorer} @@ -330,52 +400,45 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo */ public Start() { if (!this.attached) return; + if (this.node.hasAttribute('tabindex')) { + this.node.removeAttribute('tabindex'); + } if (this.active) return; + if (this.restarted !== null) { + // Here we refocus after a restart: We either find the previously focused + // node or we assume that it is inside the collapsed expression tree and + // focus on the collapsed element. + this.current = + this.node.querySelector(`[data-semantic-id="${this.restarted}"]`) || + this.node.querySelector(`[data-semantic-type="dummy"]`); + this.restarted = null; + } + if (!this.current) { + // In case something went wrong when focusing or restarting, we start on + // the root node by default. + this.current = this.node.childNodes[0] as HTMLElement; + } + let promise = Sre.sreReady(); + if (this.generators.update(this.document.options)) { + promise = promise.then( + () => this.Speech() + ); + }; this.current.setAttribute('tabindex', '0'); this.current.focus(); super.Start(); - // let options = this.getOptions(); - // if (!this.init) { - // this.init = true; - // SpeechExplorer.updatePromise = SpeechExplorer.updatePromise.then(async () => { - // return Sre.sreReady() - // .then(() => Sre.setupEngine({locale: options.locale})) - // .then(() => { - // // Important that both are in the same block so speech explorers - // // are restarted sequentially. - // this.Speech(this.walker); - // }) - // .then(() => Sre.setupEngine({automark: false as any, markup: 'none', - // locale: 'nemeth', domain: 'default', - // style: 'default', modality: 'braille'})) - // .then(() => { - // this.speechGenerator.setOptions({automark: false as any, markup: 'none', - // locale: 'nemeth', domain: 'default', - // style: 'default', modality: 'braille'}); - // this.Speech(this.walker); - // this.Start(); - // }); - // }) - // return; - // } - // this.speechGenerator = Sre.getSpeechGenerator('Direct'); - // this.speechGenerator.setOptions(options); - // this.walker = Sre.getWalker( - // 'table', this.node, this.speechGenerator, this.highlighter, this.mml); - // this.walker.activate(); if (this.document.options.a11y.subtitles) { - SpeechExplorer.updatePromise.then( - () => this.region.Show(this.node, this.highlighter)) + promise.then( + () => this.region.Show(this.node, this.highlighter)); } if (this.document.options.a11y.viewBraille) { - SpeechExplorer.updatePromise.then( - () => this.brailleRegion.Show(this.node, this.highlighter)) + promise.then( + () => this.brailleRegion.Show(this.node, this.highlighter)); } if (this.document.options.a11y.keyMagnifier) { this.magnifyRegion.Show(this.node, this.highlighter); } this.Update(); - // this.restarted = true; } @@ -385,66 +448,38 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo public Update(force: boolean = false) { // TODO (v4): This is a hack to avoid double voicing on initial startup! // Make that cleaner and remove force as it is not really used! - // let noUpdate = force; if (!this.active && !force) return; this.pool.unhighlight(); - // let nodes = this.walker.getFocus(true).getNodes(); - // if (!nodes.length) { - // this.walker.refocus(); - // nodes = this.walker.getFocus().getNodes(); - // } this.pool.highlight([this.current]); this.region.node = this.node; - this.region.Update(this.current.getAttribute('data-semantic-speech')); - this.brailleRegion.Update(this.current.getAttribute('aria-braillelabel')); + this.generators.updateRegions( + this.current, + this.region, + this.brailleRegion + ); this.magnifyRegion.Update(this.current); - // let options = this.speechGenerator.getOptions(); - // This is a necessary in case speech options have changed via keypress - // during walking. - // if (options.modality === 'speech') { - // this.document.options.sre.domain = options.domain; - // this.document.options.sre.style = options.style; - // this.document.options.a11y.speechRules = - // options.domain + '-' + options.style; - // } - // Ensure this autovoicing is retained later: - // SpeechExplorer.updatePromise = SpeechExplorer.updatePromise.then(async () => { - // return Sre.sreReady() - // .then(() => Sre.setupEngine({markup: options.markup, - // modality: options.modality, - // locale: options.locale})) - // .then(() => { - // if (!noUpdate) { - // let speech = this.walker.speech(); - // this.region.Update(speech); - // } - // }); - // }); } - /** - * Computes the speech for the current expression once Sre is ready. - * @param {Walker} walker The sre walker. + * Computes the speech for the current expression. */ - public Speech(walker: Sre.walker) { - SpeechExplorer.updatePromise.then(() => { - walker.speech(); - this.node.setAttribute('hasspeech', 'true'); - this.Update(true); - if (this.restarted && this.document.options.a11y[this.showRegion]) { - this.region.Show(this.node, this.highlighter); - } - }); + public Speech() { + this.item.outputData.speech = + this.generators.updateSpeech(this.item.typesetRoot); } - /** * @override */ public KeyDown(event: KeyboardEvent) { const code = event.key; // this.walker.modifier = event.shiftKey; + if (code === 'Tab') { + return; + } + if (code === ' ') { + return; + } if (code === 'Control') { speechSynthesis.cancel(); return; @@ -467,7 +502,7 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo } if (!this.active) { if (!this.current) { - this.current = this.node.querySelector('[role="tree"]'); + this.current = this.node.querySelector('[role="treeitem"]'); } this.Start(); this.stopEvent(event); @@ -518,9 +553,8 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo return false; } - /** - * Programmatically triggers a link if the clicked mouse contains one. + * Programmatically triggers a link if the clicked mouse event contains one. */ protected triggerLinkMouse() { let node = this.current; @@ -533,32 +567,11 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo return false; } - /** - * Retrieves the speech options to sync with document options. - * @return {{[key: string]: string}} The options settings for the speech - * generator. - */ - protected getOptions(): {[key: string]: string} { - let options = this.speechGenerator.getOptions(); - let sreOptions = this.document.options.sre; - if (options.modality === 'speech' && - (options.locale !== sreOptions.locale || - options.domain !== sreOptions.domain || - options.style !== sreOptions.style)) { - options.domain = sreOptions.domain; - options.style = sreOptions.style; - options.locale = sreOptions.locale; - this.walker.update(options); - } - return options; - } - /** * @override */ public Stop() { if (this.active) { - this.current.removeAttribute('tabindex'); this.pool.unhighlight(); this.magnifyRegion.Hide(); this.region.Hide(); @@ -567,6 +580,15 @@ export class SpeechExplorer extends AbstractExplorer implements KeyExplo super.Stop(); } - + /** + * @return The semantic node that is currently focused. + */ + public semanticFocus() { + const node = this.current || this.node; + const id = node.getAttribute('data-semantic-id'); + const stree = this.generators.speechGenerator.getRebuilt().stree; + const snode = stree.root.querySelectorAll((x: any) => x.id.toString() === id)[0]; + return snode || stree.root; + } } diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index aeafde224..3be434267 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -26,7 +26,7 @@ import {MathDocument} from '../../core/MathDocument.js'; import {CssStyles} from '../../util/StyleList.js'; import {Sre} from '../sre.js'; -import {SsmlElement, buildSpeech} from '../SpeechUtil.js'; +import {SsmlElement, buildSpeech} from '../speech/SpeechUtil.js'; export type A11yDocument = MathDocument; diff --git a/ts/a11y/semantic-enrich.ts b/ts/a11y/semantic-enrich.ts index c89142d55..98bd7161d 100644 --- a/ts/a11y/semantic-enrich.ts +++ b/ts/a11y/semantic-enrich.ts @@ -21,7 +21,6 @@ * @author dpvc@mathjax.org (Davide Cervone) */ -import {mathjax} from '../mathjax.js'; import {Handler} from '../core/Handler.js'; import {MathDocument, AbstractMathDocument, MathDocumentConstructor} from '../core/MathDocument.js'; import {MathItem, AbstractMathItem, STATE, newState} from '../core/MathItem.js'; @@ -31,15 +30,11 @@ import {MathML} from '../input/mathml.js'; import {SerializedMmlVisitor} from '../core/MmlTree/SerializedMmlVisitor.js'; import {OptionList, expandable} from '../util/Options.js'; import {Sre} from './sre.js'; -import { buildSpeech, setAria } from './SpeechUtil.js'; +import { buildSpeech } from './speech/SpeechUtil.js'; -/*==========================================================================*/ +import { GeneratorPool } from './speech/GeneratorPool.js'; -/** - * The current speech setting for Sre - */ -let currentLocale = 'none'; -let currentBraille = 'none'; +/*==========================================================================*/ /** * Generic constructor for Mixins @@ -108,6 +103,11 @@ export class enrichVisitor extends SerializedMmlVisitor { */ export interface EnrichedMathItem extends MathItem { + /** + * The speech generators for this math item. + */ + generatorPool: GeneratorPool; + /** * @param {MathDocument} document The document where enrichment is occurring * @param {boolean} force True to force the enrichment even if not enabled @@ -142,9 +142,14 @@ export function EnrichedMathItemMixin(); + + /** + * The MathML adaptor. */ - public generator = Sre.getSpeechGenerator('Tree'); + public toMathML = toMathML; /** * @param {any} node The node to be serialized @@ -175,22 +180,7 @@ export function EnrichedMathItemMixin, force: boolean = false) { if (this.state() >= STATE.ENRICHED) return; if (!this.isEscaped && (document.options.enableEnrichment || force)) { - // TODO: Sort out the loading of the locales better - if (document.options.enableSpeech) { - if (document.options.sre.locale !== currentLocale) { - currentLocale = document.options.sre.locale; - // TODO: Sort out the loading of the locales better - mathjax.retryAfter( - Sre.setupEngine({locale: document.options.sre.locale}) - .then(() => Sre.sreReady())); - } - if (document.options.sre.braille !== currentBraille) { - currentBraille = document.options.sre.braille; - mathjax.retryAfter( - Sre.setupEngine({locale: document.options.sre.braille}) - .then(() => Sre.sreReady())); - } - } + this.generatorPool.init(document.options, document.adaptor); const math = new document.options.MathItem('', MmlJax); try { let mml; @@ -199,27 +189,7 @@ export function EnrichedMathItemMixin) { if (this.state() >= STATE.ATTACHSPEECH) return; - const attributes = this.root.attributes; - const speech = (attributes.get('aria-label') || this.outputData.speech); - const braille = (attributes.get('aria-braillelabel') || this.outputData.braille); + let [speech, braille] = this.existingSpeech(); + let [newSpeech, newBraille] = ['', '']; + if (!speech || !braille || + document.options.enableSpeech || document.options.enableBraille) { + [newSpeech, newBraille] = this.generatorPool.computeSpeech( + this.typesetRoot, this.toMathML(this.root, this)); + } + speech = speech || newSpeech; + braille = braille || newBraille; if (!speech && !braille) { this.state(STATE.ATTACHSPEECH); return; @@ -379,7 +372,6 @@ export function EnrichedMathDocumentMixin { + + private _element: Element; + + set element(element: Element) { + this._element = element; + // We always force a rebuild of the semantic tree, in case the element was + // re-rendered. Otherwise we might have incorrect maction links etc. + const rebuilt = this.speechGenerator.computeRebuilt(element, true); + this.brailleGenerator.setRebuilt(rebuilt); + this.summaryGenerator.setRebuilt(rebuilt); + } + + get element() { + return this._element; + } + + /** + * The adaptor to work with typeset nodes. + */ + public adaptor: DOMAdaptor = null; + + /** + * The speech generator for a math item. + */ + public speechGenerator = Sre.getSpeechGenerator('Tree'); + + /** + * The braille generator for a math item. + */ + public brailleGenerator = Sre.getSpeechGenerator('Tree'); + + /** + * The summary generator for a math item. + */ + public summaryGenerator = Sre.getSpeechGenerator('Summary'); + + /** + * The current speech setting for Sre + */ + private currentLocale = 'none'; + private currentBraille = 'none'; + private _options: OptionList = {}; + + /** + * Option setter that takes care of setting up SRE and assembling the options + * for the speech generators. + * + * @param {OptionList} options The option list. + */ + public set options(options: OptionList) { + this._options = options; + Sre.setupEngine(options.sre); + this.speechGenerator.setOptions(Object.assign( + {}, options?.sre || {}, { + modality: 'speech', + markup: 'ssml', + automark: true + })); + this.summaryGenerator.setOptions(Object.assign( + {}, options?.sre || {}, { + modality: 'summary', + markup: 'ssml', + automark: true, + })); + this.brailleGenerator.setOptions({ + locale: options?.sre?.braille, + domain: 'default', + style: 'default', + modality: 'braille', + markup: 'none', + }); + } + + public get options() { + return this._options; + } + + private _init = false; + + /** + * Init method for speech generation. Runs a retry until locales have been + * loaded. + * + * @param {OptionList} options A list of options. + */ + public init(options: OptionList, adaptor: DOMAdaptor) { + if (this._init) return; + this.adaptor = adaptor; + this.options = options; + this._init = true; + if (this._update(options)) { + mathjax.retryAfter(Sre.sreReady()); + } + } + + /** + * Update method for speech generation options. Runs a retry until locales have been + * loaded. + * + * @param {OptionList} options A list of options. + */ + public update(options: OptionList) { + this.options = options; + return this._update(options); + } + + private _update(options: OptionList) { + if (!options || !options.sre) return false; + let update = false; + if (options.sre.braille !== this.currentBraille) { + this.currentBraille = options.sre.braille; + update = true; + Sre.setupEngine({locale: options.sre.braille}) + } + if (options.sre.locale !== this.currentLocale) { + this.currentLocale = options.sre.locale; + update = true; + Sre.setupEngine({locale: options.sre.locale}) + } + return update; + } + + /** + * Compute speech using the original MathML element as reference. + * + * @param {N} node The typeset node. + * @param {string} mml The serialized mml node. + * @return {[string, string]} Speech and Braille expression pair. + */ + public computeSpeech(node: N, mml: string): [string, string] { + this.element = Sre.parseDOM(mml); + const xml = this.prepareXml(node); + const speech = this.speechGenerator.getSpeech(xml, this.element); + const braille = this.brailleGenerator.getSpeech(xml, this.element); + if (this.options.enableSpeech || this.options.enableBraille) { + this.setAria(node, xml, this.options.sre.locale); + } + return [speech, braille]; + } + + /** + * Computes the summary for the current node. Summary computations are very + * fast, and we recompute in case the rule sets have changed and there is a + * different summary. + * + * @param {N} node The typeset node. + */ + public summary(node: N) { + if (this.lastSummary) { + this.CleanUp(node); + return this.lastSpeech; + } + const xml = this.prepareXml(node); + this.lastSpeech = this.summaryGenerator.getSpeech(xml, this.element) + return this.lastSpeech; + } + + /** + * Cleans up after an explorer move by replacing the aria-label with the + * original speech again. + * + * @param {N} node + */ + public CleanUp(node: N) { + if (this.lastSummary) { + // TODO: Remember the speech. + this.adaptor.setAttribute(node, 'aria-label', buildSpeech(this.getLabel(node))[0]); + } + this.lastSummary = false; + } + + /** + * Remembers the last speech element after a summary computation. + */ + private lastSpeech = ''; + + /** + * Remembers that the last speech computation was a summary. + */ + private lastSummary = false; + + /** + * Updates the given speech regions, possibly reinstanting previously saved + * speech. + * + * @param {N} node The typeset node + * @param {LiveRegion} speechRegion The speech region. + * @param {LiveRegion} brailleRegion The braille region. + */ + public updateRegions( + node: N, + speechRegion: LiveRegion, + brailleRegion: LiveRegion + ) { + let speech = this.getLabel(node, this.lastSpeech); + speechRegion.Update(speech); + // TODO: See if we can reuse the speech from the speech region. + this.adaptor.setAttribute(node, 'aria-label', buildSpeech(speech)[0]); + if (this.lastSpeech) { + this.lastSummary = true; + } + this.lastSpeech = ''; + brailleRegion.Update( + this.adaptor.getAttribute(node, 'aria-braillelabel')); + } + + /** + * Updates the speech in the give node. + * + * @param {N} node The typeset node. + */ + public updateSpeech(node: N) { + const xml = this.prepareXml(node); + const speech = this.speechGenerator.getSpeech(xml, this.element); + this.setAria(node, xml, this.options.sre.locale); + const label = buildSpeech(speech)[0]; + this.adaptor.setAttribute(node, 'aria-label', label); + return label; + } + + /** + * Cycles rule sets for the speech generator. + * + * @param {N} _node The typeset node. + */ + public nextRules(_node: N) { + this.speechGenerator.nextRules(); + this.updateSummaryGenerator(); + } + + /** + * Cycles style or preference settings for the speech generator. + * + * @param {N} node The typeset node. + */ + public nextStyle(node: N) { + this.speechGenerator.nextStyle( + this.adaptor.getAttribute(node, 'data-semantic-id')); + this.updateSummaryGenerator(); + } + + /** + * Copies domain and style option from speech to summary generator. This is + * necessary after when either option is changed on the fly. + */ + private updateSummaryGenerator() { + const options = this.speechGenerator.getOptions(); + this.summaryGenerator.setOption('domain', options['domain']); + this.summaryGenerator.setOption('style', options['style']); + } + + /** + * Makes a node amenable for SRE computations by reparsing. + * + * @param {N} node The node. + */ + private prepareXml(node: N) { + return Sre.parseDOM(this.adaptor.serializeXML(node)); + } + + /** + * Speech, labels and aria + */ + + /** + * Computes the speech label from the node combining prefixes and postfixes. + * + * @param {N} node The typeset node. + * @param {string=} center Core speech. Defaults to `data-semantic-speech`. + * @param {string=} sep The speech separator. Defaults to space. + */ + public getLabel(node: N, + center: string = '', + sep: string = ' ') { + return buildLabel( + center || this.adaptor.getAttribute(node, 'data-semantic-speech'), + this.adaptor.getAttribute(node, 'data-semantic-prefix'), + // TODO: check if we need this or if it is automatic by the screen readers. + this.adaptor.getAttribute(node, 'data-semantic-postfix'), + sep + ); + } + + /** + * Copies an attribute from the enriched element to the current typeset node. + * + * @param {Element} xml The enriched XML. + * @param {N} node The typeset node. + * @param {string} attr The attribute to copy. + */ + private copyAttributes(xml: Element, node: N, attr: string) { + const value = xml.getAttribute(attr); + if (value !== undefined && value !== null) { + this.adaptor.setAttribute(node, attr, value); + } + } + + /** + * Attributes to be copied after updating speech. + */ + private attrList: string[] = [ + 'data-semantic-prefix', + 'data-semantic-postfix', + 'data-semantic-speech', + 'data-semantic-braille', + ] + + /** + * Attributes to be copied after an element was collapsed. + */ + private dummyList: string[] = [ + 'data-semantic-id', + 'data-semantic-parent', + 'data-semantic-type', + 'data-semantic-role', + 'role' + ] + + /** + * Retrieve and sets aria and braille labels recursively. + * @param {MmlNode} node The root node to search from. + */ + public setAria(node: N, xml: Element, locale: string) { + const kind = xml.getAttribute('data-semantic-type'); + if (kind) { + this.attrList.forEach(attr => this.copyAttributes(xml, node, attr)); + if (kind === 'dummy') { + this.dummyList.forEach(attr => this.copyAttributes(xml, node, attr)); + } + } + const speech = this.getLabel(node); + if (speech) { + this.adaptor.setAttribute(node, 'aria-label', buildSpeech(speech, locale)[0]); + } + const braille = this.adaptor.getAttribute(node, 'data-semantic-braille'); + if (braille) { + this.adaptor.setAttribute(node, 'aria-braillelabel', braille); + } + const xmlChildren = Array.from(xml.childNodes); + Array.from(this.adaptor.childNodes(node)).forEach( + (child, index) => { + if (this.adaptor.kind(child) !== '#text' && + this.adaptor.kind(child) !== '#comment') { + this.setAria(child as N, xmlChildren[index] as Element, locale); + } + } + ); + } + +} diff --git a/ts/a11y/SpeechMenu.ts b/ts/a11y/speech/SpeechMenu.ts similarity index 88% rename from ts/a11y/SpeechMenu.ts rename to ts/a11y/speech/SpeechMenu.ts index b06b469e9..0e6b8dbd2 100644 --- a/ts/a11y/SpeechMenu.ts +++ b/ts/a11y/speech/SpeechMenu.ts @@ -21,11 +21,10 @@ * @author v.sorge@mathjax.org (Volker Sorge) */ -import { SpeechExplorer } from './explorer/KeyExplorer.js'; -import { ExplorerMathItem } from './explorer.js'; -import {MJContextMenu} from '../ui/menu/MJContextMenu.js'; -import {SubMenu, Submenu} from '../ui/menu/mj-context-menu.js'; -import {Sre} from './sre.js'; +import { ExplorerMathItem } from '../explorer.js'; +import {MJContextMenu} from '../../ui/menu/MJContextMenu.js'; +import {SubMenu, Submenu} from '../../ui/menu/mj-context-menu.js'; +import {Sre} from '../sre.js'; /** * Values for the ClearSpeak preference variables. @@ -170,16 +169,13 @@ export function clearspeakMenu(menu: MJContextMenu, sub: Submenu) { let locale = menu.pool.lookup('locale').getValue() as string; const box = csSelectionBox(menu, locale); let items: Object[] = []; - let explorer = (menu.mathItem as ExplorerMathItem)?. - explorers?.explorers?.speech as SpeechExplorer; - if (explorer?.walker) { - let semantic = explorer.walker.getFocus()?.getSemanticPrimary(); - if (semantic) { - const previous = Sre.clearspeakPreferences.currentPreference(); - const smart = Sre.clearspeakPreferences.relevantPreferences(semantic); - items = items.concat(basePreferences(previous)); - items = items.concat(smartPreferences(previous, smart, locale)); - } + const explorer = (menu.mathItem as ExplorerMathItem)?.explorers?.speech; + const semantic = explorer?.semanticFocus(); + const previous = Sre.clearspeakPreferences.currentPreference(); + items = items.concat(basePreferences(previous)); + if (semantic) { + const smart = Sre.clearspeakPreferences.relevantPreferences(semantic); + items = items.concat(smartPreferences(previous, smart, locale)); } if (box) { items.splice(2, 0, box); diff --git a/ts/a11y/SpeechUtil.ts b/ts/a11y/speech/SpeechUtil.ts similarity index 82% rename from ts/a11y/SpeechUtil.ts rename to ts/a11y/speech/SpeechUtil.ts index 2b66bbee1..67278a9b4 100644 --- a/ts/a11y/SpeechUtil.ts +++ b/ts/a11y/speech/SpeechUtil.ts @@ -21,8 +21,7 @@ * @author v.sorge@mathjax.org (Volker Sorge) */ -import {MmlNode} from '../core/MmlTree/MmlNode.js'; -import Sre from './sre.js'; +import Sre from '../sre.js'; const ProsodyKeys = [ 'pitch', 'rate', 'volume' ]; @@ -157,24 +156,28 @@ function extractProsody(attr: string) { return [match[1], match[2]]; } + +/** + * Speech, labels and aria + */ + /** - * Computes the aria-label from the node. - * @param {MmlNode} node The Math element. - * @param {string=} sep The speech separator. Defaults to space. + * Builds a speech label from input components. + * + * @param speech The speech string. + * @param prefix The prefix expression. + * @param postfix The postfix expression. + * @param sep The separator string. Defaults to space. */ -function getLabel(node: MmlNode, sep: string = ' ') { - const attributes = node.attributes; - const speech = attributes.getExplicit('data-semantic-speech') as string; +export function buildLabel( + speech: string, prefix: string, postfix: string, sep: string = ' ') { if (!speech) { return ''; } const label = [speech]; - const prefix = attributes.getExplicit('data-semantic-prefix') as string; if (prefix) { label.unshift(prefix); } - // TODO: check if we need this or if it is automatic by the screen readers. - const postfix = attributes.getExplicit('data-semantic-postfix') as string; if (postfix) { label.push(postfix); } @@ -199,26 +202,6 @@ export function buildSpeech(speech: string, locale: string = 'en', ''); } -/** - * Retrieve and sets aria and braille labels recursively. - * @param {MmlNode} node The root node to search from. - */ -export function setAria(node: MmlNode, locale: string) { - const attributes = node.attributes; - if (!attributes) return; - const speech = getLabel(node); - if (speech) { - attributes.set('aria-label', buildSpeech(speech, locale)[0]); - } - const braille = node.attributes.getExplicit('data-semantic-braille') as string; - if (braille) { - attributes.set('aria-braillelabel', braille); - } - for (let child of node.childNodes) { - setAria(child, locale); - } -} - /** * Creates a honking sound. */ diff --git a/ts/a11y/sre.ts b/ts/a11y/sre.ts index ae8b6b975..48ed8f90c 100644 --- a/ts/a11y/sre.ts +++ b/ts/a11y/sre.ts @@ -23,10 +23,8 @@ */ import * as Api from '#sre/common/system.js'; -import {Walker} from '#sre/walker/walker.js'; -import * as WalkerFactory from '#sre/walker/walker_factory.js'; import * as SpeechGeneratorFactory from '#sre/speech_generator/speech_generator_factory.js'; -import Engine from '#sre/common/engine.js'; +import { Engine } from '#sre/common/engine.js'; import {ClearspeakPreferences} from '#sre/speech_rules/clearspeak_preferences.js'; import {Highlighter} from '#sre/highlighter/highlighter.js'; import * as HighlighterFactory from '#sre/highlighter/highlighter_factory.js'; @@ -41,9 +39,6 @@ export namespace Sre { export type speechGenerator = SpeechGenerator; - export type walker = Walker; - - export const locales = Variables.LOCALES; export const sreReady = Api.engineReady; @@ -64,8 +59,6 @@ export namespace Sre { export const getSpeechGenerator = SpeechGeneratorFactory.generator; - export const getWalker = WalkerFactory.walker; - export const parseDOM = parseInput; /** diff --git a/ts/ui/menu/Menu.ts b/ts/ui/menu/Menu.ts index de861d8a1..b1945185a 100644 --- a/ts/ui/menu/Menu.ts +++ b/ts/ui/menu/Menu.ts @@ -492,7 +492,7 @@ export class Menu { this.a11yVar('locale', value => { MathJax._.a11y.sre.Sre.setupEngine({locale: value as string}); }), - this.a11yVar ('speechRules', value => { + this.a11yVar('speechRules', value => { const [domain, style] = value.split('-'); this.document.options.sre.domain = domain; this.document.options.sre.style = style; diff --git a/ts/ui/menu/MenuHandler.ts b/ts/ui/menu/MenuHandler.ts index a64d41ec3..d811a2f23 100644 --- a/ts/ui/menu/MenuHandler.ts +++ b/ts/ui/menu/MenuHandler.ts @@ -32,7 +32,7 @@ import {AssistiveMmlMathDocument, AssistiveMmlMathItem} from '../../a11y/assisti import {expandable} from '../../util/Options.js'; import {Menu} from './Menu.js'; -import '../../a11y/SpeechMenu.js'; +import '../../a11y/speech/SpeechMenu.js'; /*==========================================================================*/