implements Explorer {
AbstractExplorer.stopEvent(event);
}
}
-
}
diff --git a/ts/a11y/explorer/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts
new file mode 100644
index 000000000..f497f1575
--- /dev/null
+++ b/ts/a11y/explorer/ExplorerPool.ts
@@ -0,0 +1,430 @@
+/*************************************************************
+ *
+ * COPYRIGHT (c) 2022-2024 The MathJax Consortium
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @file Class for handling all explorers on a single Math Item.
+ *
+ * @author v.sorge@mathjax.org (Volker Sorge)
+ */
+
+import { LiveRegion, SpeechRegion, ToolTip, HoverRegion } from './Region.js';
+import type { ExplorerMathDocument, ExplorerMathItem } from '../explorer.js';
+
+import { Explorer } from './Explorer.js';
+import { SpeechExplorer } from './KeyExplorer.js';
+import * as me from './MouseExplorer.js';
+import { TreeColorer, FlameColorer } from './TreeExplorer.js';
+
+import { Highlighter, getHighlighter } from './Highlighter.js';
+// import * as Sre from '../sre.js';
+
+/**
+ * The regions objects needed for the explorers.
+ */
+export class RegionPool {
+ /**
+ * The speech region.
+ */
+ public speechRegion: SpeechRegion = new SpeechRegion(this.document);
+
+ /**
+ * The Braille region.
+ */
+ public brailleRegion: LiveRegion = new LiveRegion(this.document);
+
+ /**
+ * Hover region for all magnifiers.
+ */
+ public magnifier: HoverRegion = new HoverRegion(this.document);
+
+ /**
+ * A tooltip region.
+ */
+ public tooltip1: ToolTip = new ToolTip(this.document);
+
+ /**
+ * A tooltip region.
+ */
+ public tooltip2: ToolTip = new ToolTip(this.document);
+
+ /**
+ * A tooltip region.
+ */
+ public tooltip3: ToolTip = new ToolTip(this.document);
+
+ /**
+ * @param {ExplorerMathDocument} document The document the handler belongs to.
+ */
+ constructor(public document: ExplorerMathDocument) {}
+}
+
+/**
+ * Type of explorer initialization methods.
+ *
+ * @type {(doc: ExplorerMathDocument,
+ * pool: ExplorerPool,
+ * node: HTMLElement,
+ * ...rest: any[]
+ * ) => Explorer}
+ */
+type ExplorerInit = (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ...rest: any[]
+) => Explorer;
+
+/**
+ * Generation methods for all MathJax explorers available via option settings.
+ */
+const allExplorers: { [options: string]: ExplorerInit } = {
+ speech: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ...rest: any[]
+ ) => {
+ const explorer = SpeechExplorer.create(
+ doc,
+ pool,
+ doc.explorerRegions.speechRegion,
+ node,
+ doc.explorerRegions.brailleRegion,
+ doc.explorerRegions.magnifier,
+ rest[0],
+ rest[1]
+ ) as SpeechExplorer;
+ explorer.sound = true;
+ return explorer;
+ },
+ mouseMagnifier: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ..._rest: any[]
+ ) =>
+ me.ContentHoverer.create(
+ doc,
+ pool,
+ doc.explorerRegions.magnifier,
+ node,
+ (x: HTMLElement) => x.hasAttribute('data-semantic-type'),
+ (x: HTMLElement) => x
+ ),
+ hover: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ..._rest: any[]
+ ) => me.FlameHoverer.create(doc, pool, null, node),
+ infoType: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ..._rest: any[]
+ ) =>
+ me.ValueHoverer.create(
+ doc,
+ pool,
+ doc.explorerRegions.tooltip1,
+ node,
+ (x: HTMLElement) => x.hasAttribute('data-semantic-type'),
+ (x: HTMLElement) => x.getAttribute('data-semantic-type')
+ ),
+ infoRole: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ..._rest: any[]
+ ) =>
+ me.ValueHoverer.create(
+ doc,
+ pool,
+ doc.explorerRegions.tooltip2,
+ node,
+ (x: HTMLElement) => x.hasAttribute('data-semantic-role'),
+ (x: HTMLElement) => x.getAttribute('data-semantic-role')
+ ),
+ infoPrefix: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ..._rest: any[]
+ ) =>
+ me.ValueHoverer.create(
+ doc,
+ pool,
+ doc.explorerRegions.tooltip3,
+ node,
+ (x: HTMLElement) => x.hasAttribute?.('data-semantic-prefix-none'),
+ (x: HTMLElement) => x.getAttribute?.('data-semantic-prefix-none')
+ ),
+ flame: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ..._rest: any[]
+ ) => FlameColorer.create(doc, pool, null, node),
+ treeColoring: (
+ doc: ExplorerMathDocument,
+ pool: ExplorerPool,
+ node: HTMLElement,
+ ...rest: any[]
+ ) => TreeColorer.create(doc, pool, null, node, ...rest),
+};
+
+/**
+ * Class to bundle and handle all explorers on a Math item. This in particular
+ * means that all explorer share the same highlighter, meaning that there is no
+ * uncontrolled interaction between highlighting of different explorers.
+ */
+export class ExplorerPool {
+ /**
+ * A highlighter that is used to mark nodes during auto voicing.
+ */
+ public secondaryHighlighter: Highlighter;
+
+ /**
+ * The explorer dictionary.
+ */
+ public explorers: { [key: string]: Explorer } = {};
+
+ /**
+ * The currently attached explorers
+ */
+ protected attached: string[] = [];
+
+ /**
+ * The target document.
+ */
+ protected document: ExplorerMathDocument;
+
+ /**
+ * The node explorers will be attached to.
+ */
+ protected node: HTMLElement;
+
+ /**
+ * The corresponding Mathml node as a string.
+ */
+ protected mml: string;
+
+ /**
+ * The primary highlighter shared by all explorers.
+ */
+ private _highlighter: Highlighter;
+
+ /**
+ * The name of the current output jax.
+ */
+ private _renderer: string;
+
+ /**
+ * All explorers that need to be restarted on a rerendered element.
+ */
+ private _restart: string[] = [];
+
+ /**
+ * @returns {Highlighter} The primary highlighter shared by all explorers.
+ */
+ public get highlighter(): Highlighter {
+ if (this._renderer !== this.document.outputJax.name) {
+ this._renderer = this.document.outputJax.name;
+ this.setPrimaryHighlighter();
+ return this._highlighter;
+ }
+ const [foreground, background] = this.colorOptions();
+ this._highlighter.setColor(background, foreground);
+ return this._highlighter;
+ }
+
+ /**
+ * @param {ExplorerMathDocument} document The target document.
+ * @param {HTMLElement} node The node explorers will be attached to.
+ * @param {string} mml The corresponding Mathml node as a string.
+ * @param {ExplorerMathItem} item The current math item.
+ */
+ public init(
+ document: ExplorerMathDocument,
+ node: HTMLElement,
+ mml: string,
+ item: ExplorerMathItem
+ ) {
+ this.document = document;
+ this.mml = mml;
+ this.node = node;
+ this.setPrimaryHighlighter();
+ for (const key of Object.keys(allExplorers)) {
+ this.explorers[key] = allExplorers[key](
+ this.document,
+ this,
+ this.node,
+ this.mml,
+ item
+ );
+ }
+ this.setSecondaryHighlighter();
+ this.attach();
+ }
+
+ /**
+ * A11y options keys associated with the speech explorer.
+ */
+ private speechExplorerKeys = ['speech', 'braille', 'keyMagnifier'];
+
+ /**
+ * Attaches the explorers that are currently meant to be active given
+ * the document options. Detaches all others.
+ */
+ public attach() {
+ this.attached = [];
+ const keyExplorers = [];
+ const a11y = this.document.options.a11y;
+ for (const [key, explorer] of Object.entries(this.explorers)) {
+ if (explorer instanceof SpeechExplorer) {
+ explorer.stoppable = false;
+ keyExplorers.unshift(explorer);
+ if (
+ this.speechExplorerKeys.some(
+ (exKey) => this.document.options.a11y[exKey]
+ )
+ ) {
+ explorer.Attach();
+ this.attached.push(key);
+ } else {
+ explorer.Detach();
+ }
+ continue;
+ }
+ if (
+ a11y[key] ||
+ (key === 'speech' && (a11y.braille || a11y.keyMagnifier))
+ ) {
+ explorer.Attach();
+ this.attached.push(key);
+ } else {
+ explorer.Detach();
+ }
+ }
+ // Ensure that the last currently attached key explorer stops propagating
+ // key events.
+ for (const explorer of keyExplorers) {
+ if (explorer.attached) {
+ explorer.stoppable = true;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Computes the explorers that need to be reattached after a MathItem is
+ * rerendered.
+ */
+ public reattach() {
+ for (const key of this.attached) {
+ const explorer = this.explorers[key];
+ if (explorer.active) {
+ this._restart.push(key);
+ explorer.Stop();
+ }
+ }
+ }
+
+ /**
+ * Restarts explorers after a MathItem is rerendered.
+ */
+ public restart() {
+ this._restart.forEach((x) => {
+ this.explorers[x].Start();
+ });
+ this._restart = [];
+ }
+
+ /**
+ * A highlighter for the explorer.
+ */
+ protected setPrimaryHighlighter() {
+ const [foreground, background] = this.colorOptions();
+ this._highlighter = getHighlighter(
+ background,
+ foreground,
+ this.document.outputJax.name
+ );
+ }
+
+ /**
+ * Sets the secondary highlighter for marking nodes during autovoicing.
+ */
+ protected setSecondaryHighlighter() {
+ this.secondaryHighlighter = getHighlighter(
+ { color: 'red' },
+ { color: 'black' },
+ this.document.outputJax.name
+ );
+ (this.speech.region as SpeechRegion).highlighter =
+ this.secondaryHighlighter;
+ }
+
+ /**
+ * Highlights a set of DOM nodes.
+ *
+ * @param {HTMLElement[]} nodes The array of HTML nodes to be highlighted.
+ */
+ public highlight(nodes: HTMLElement[]) {
+ this.highlighter.highlight(nodes);
+ }
+
+ /**
+ * Unhighlights the currently highlighted DOM nodes.
+ */
+ public unhighlight() {
+ this.secondaryHighlighter.unhighlight();
+ this.highlighter.unhighlight();
+ }
+
+ /**
+ * Convenience method to return the speech explorer of the pool with the
+ * correct type.
+ *
+ * @returns {SpeechExplorer} The speech explorer.
+ */
+ public get speech(): SpeechExplorer {
+ return this.explorers['speech'] as SpeechExplorer;
+ }
+
+ /**
+ * Retrieves color assignment for the document options.
+ *
+ * @returns {[ { color: string; alpha: number }, { color: string; alpha:
+ * number } ]} Color assignments for fore and background colors.
+ */
+ private colorOptions(): [
+ { color: string; alpha: number },
+ { color: string; alpha: number },
+ ] {
+ const opts = this.document.options.a11y;
+ const foreground = {
+ color: opts.foregroundColor.toLowerCase(),
+ alpha: opts.foregroundOpacity / 100,
+ };
+ const background = {
+ color: opts.backgroundColor.toLowerCase(),
+ alpha: opts.backgroundOpacity / 100,
+ };
+ return [foreground, background];
+ }
+}
diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts
new file mode 100644
index 000000000..7f520914c
--- /dev/null
+++ b/ts/a11y/explorer/Highlighter.ts
@@ -0,0 +1,486 @@
+//
+// Copyright 2025 Volker Sorge
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @file Highlighter for exploring nodes.
+ * @author v.sorge@mathjax.org (Volker Sorge)
+ */
+
+interface NamedColor {
+ color: string;
+ alpha?: number;
+}
+
+interface ChannelColor {
+ red: number;
+ green: number;
+ blue: number;
+ alpha?: number;
+}
+
+const namedColors: { [key: string]: ChannelColor } = {
+ red: { red: 255, green: 0, blue: 0 },
+ green: { red: 0, green: 255, blue: 0 },
+ blue: { red: 0, green: 0, blue: 255 },
+ yellow: { red: 255, green: 255, blue: 0 },
+ cyan: { red: 0, green: 255, blue: 255 },
+ magenta: { red: 255, green: 0, blue: 255 },
+ white: { red: 255, green: 255, blue: 255 },
+ black: { red: 0, green: 0, blue: 0 },
+};
+
+/**
+ * Turns a named color into a channel color.
+ *
+ * @param {NamedColor} color The definition.
+ * @param {NamedColor} deflt The default color name if the named color does not exist.
+ * @returns {string} The channel color.
+ */
+function getColorString(color: NamedColor, deflt: NamedColor): string {
+ const channel = namedColors[color.color] || namedColors[deflt.color];
+ channel.alpha = color.alpha ?? deflt.alpha;
+ return rgba(channel);
+}
+
+/**
+ * RGBa string version of the channel color.
+ *
+ * @param {ChannelColor} color The channel color.
+ * @returns {string} The color in RGBa format.
+ */
+function rgba(color: ChannelColor): string {
+ return `rgba(${color.red},${color.green},${color.blue},${color.alpha ?? 1})`;
+}
+
+/**
+ * The default background color if a none existing color is provided.
+ */
+const DEFAULT_BACKGROUND: NamedColor = { color: 'blue', alpha: 1 };
+
+/**
+ * The default color if a none existing color is provided.
+ */
+const DEFAULT_FOREGROUND: NamedColor = { color: 'black', alpha: 1 };
+
+export interface Highlighter {
+ /**
+ * Sets highlighting on a node.
+ *
+ * @param {HTMLElement} nodes The nodes to highlight.
+ */
+ highlight(nodes: HTMLElement[]): void;
+
+ /**
+ * Unhighlights the last nodes that were highlighted.
+ */
+ unhighlight(): void;
+
+ /**
+ * Sets highlighting on all maction-like sub nodes of the given node.
+ *
+ * @param {HTMLElement} node The node to highlight.
+ */
+ highlightAll(node: HTMLElement): void;
+
+ /**
+ * Unhighlights all currently highlighted nodes.
+ */
+ unhighlightAll(): void;
+
+ /**
+ * Predicate to check if a node is an maction node.
+ *
+ * @param {Element} node A DOM node.
+ * @returns {boolean} True if the node is an maction node.
+ */
+ isMactionNode(node: Element): boolean;
+
+ /**
+ * @returns {string} The foreground color as rgba string.
+ */
+ get foreground(): string;
+
+ /**
+ * @returns {string} The background color as rgba string.
+ */
+ get background(): string;
+
+ /**
+ * Sets of the color the highlighter is using.
+ *
+ * @param {NamedColor} background The new background color to use.
+ * @param {NamedColor} foreground The new foreground color to use.
+ */
+ setColor(background: NamedColor, foreground: NamedColor): void;
+}
+
+/**
+ * Highlight information consisting of node, fore and background color.
+ */
+interface Highlight {
+ node: HTMLElement;
+ background?: string;
+ foreground?: string;
+}
+
+let counter = 0;
+
+abstract class AbstractHighlighter implements Highlighter {
+ /**
+ * This counter creates a unique highlighter name. This is important in case
+ * we have more than a single highlighter on a node, e.g., during auto voicing
+ * with synchronised highlighting.
+ */
+ public counter = counter++;
+
+ /**
+ * The Attribute for marking highlighted nodes.
+ */
+ protected ATTR = 'sre-highlight-' + this.counter.toString();
+
+ /**
+ * The foreground color.
+ */
+ private _foreground: string;
+
+ /**
+ * The background color.
+ */
+ private _background: string;
+
+ /**
+ * The maction name/class for a highlighter.
+ */
+ protected mactionName = '';
+
+ /**
+ * List of currently highlighted nodes and their original background color.
+ */
+ private currentHighlights: Highlight[][] = [];
+
+ /**
+ * Highlights a single node.
+ *
+ * @param node The node to be highlighted.
+ * @returns The old node information.
+ */
+ protected abstract highlightNode(node: HTMLElement): Highlight;
+
+ /**
+ * Unhighlights a single node.
+ *
+ * @param highlight The highlight info for the node to be unhighlighted.
+ */
+ protected abstract unhighlightNode(highlight: Highlight): void;
+
+ /**
+ * @override
+ */
+ public highlight(nodes: HTMLElement[]) {
+ this.currentHighlights.push(
+ nodes.map((node) => {
+ const info = this.highlightNode(node);
+ this.setHighlighted(node);
+ return info;
+ })
+ );
+ }
+
+ /**
+ * @override
+ */
+ public highlightAll(node: HTMLElement) {
+ const mactions = this.getMactionNodes(node);
+ for (let i = 0, maction; (maction = mactions[i]); i++) {
+ this.highlight([maction]);
+ }
+ }
+
+ /**
+ * @override
+ */
+ public unhighlight() {
+ const nodes = this.currentHighlights.pop();
+ if (!nodes) {
+ return;
+ }
+ nodes.forEach((highlight: Highlight) => {
+ if (this.isHighlighted(highlight.node)) {
+ this.unhighlightNode(highlight);
+ this.unsetHighlighted(highlight.node);
+ }
+ });
+ }
+
+ /**
+ * @override
+ */
+ public unhighlightAll() {
+ while (this.currentHighlights.length > 0) {
+ this.unhighlight();
+ }
+ }
+
+ /**
+ * @override
+ */
+ public setColor(background: NamedColor, foreground: NamedColor) {
+ this._foreground = getColorString(foreground, DEFAULT_FOREGROUND);
+ this._background = getColorString(background, DEFAULT_BACKGROUND);
+ }
+
+ /**
+ * @override
+ */
+ public get foreground(): string {
+ return this._foreground;
+ }
+
+ /**
+ * @override
+ */
+ public get background(): string {
+ return this._background;
+ }
+
+ /**
+ * Returns the maction sub nodes of a given node.
+ *
+ * @param {HTMLElement} node The root node.
+ * @returns {HTMLElement[]} The list of maction sub nodes.
+ */
+ public getMactionNodes(node: HTMLElement): HTMLElement[] {
+ return Array.from(
+ node.getElementsByClassName(this.mactionName)
+ ) as HTMLElement[];
+ }
+
+ /**
+ * @override
+ */
+ public isMactionNode(node: Element): boolean {
+ const className = node.className || node.getAttribute('class');
+ return className ? !!className.match(new RegExp(this.mactionName)) : false;
+ }
+
+ /**
+ * Check if a node is already highlighted.
+ *
+ * @param {HTMLElement} node The node.
+ * @returns {boolean} True if already highlighted.
+ */
+ public isHighlighted(node: HTMLElement): boolean {
+ return node.hasAttribute(this.ATTR);
+ }
+
+ /**
+ * Sets the indicator attribute that node is already highlighted.
+ *
+ * @param {HTMLElement} node The node.
+ */
+ public setHighlighted(node: HTMLElement) {
+ node.setAttribute(this.ATTR, 'true');
+ }
+
+ /**
+ * Removes the indicator attribute that node is already highlighted.
+ *
+ * @param {HTMLElement} node The node.
+ */
+ public unsetHighlighted(node: HTMLElement) {
+ node.removeAttribute(this.ATTR);
+ }
+}
+
+class SvgHighlighter extends AbstractHighlighter {
+ /**
+ * @override
+ */
+ constructor() {
+ super();
+ this.mactionName = 'maction';
+ }
+
+ /**
+ * @override
+ */
+ public highlightNode(node: HTMLElement) {
+ let info: Highlight;
+ if (this.isHighlighted(node)) {
+ info = {
+ node: node,
+ background: this.background,
+ foreground: this.foreground,
+ };
+ return info;
+ }
+ if (node.tagName === 'svg' || node.tagName === 'MJX-CONTAINER') {
+ info = {
+ node: node,
+ background: node.style.backgroundColor,
+ foreground: node.style.color,
+ };
+ node.style.backgroundColor = this.background;
+ node.style.color = this.foreground;
+ return info;
+ }
+ // This is a hack for v4.
+ // TODO: v4 Change
+ // const rect = (document ?? DomUtil).createElementNS(
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ rect.setAttribute(
+ 'sre-highlighter-added', // Mark highlighting rect.
+ 'true'
+ );
+ const padding = 40;
+ const bbox: SVGRect = (node as any as SVGGraphicsElement).getBBox();
+ rect.setAttribute('x', (bbox.x - padding).toString());
+ rect.setAttribute('y', (bbox.y - padding).toString());
+ rect.setAttribute('width', (bbox.width + 2 * padding).toString());
+ rect.setAttribute('height', (bbox.height + 2 * padding).toString());
+ const transform = node.getAttribute('transform');
+ if (transform) {
+ rect.setAttribute('transform', transform);
+ }
+ rect.setAttribute('fill', this.background);
+ node.setAttribute(this.ATTR, 'true');
+ node.parentNode.insertBefore(rect, node);
+ info = { node: node, foreground: node.getAttribute('fill') };
+ if (node.nodeName !== 'rect') {
+ // We currently do not change foreground of collapsed nodes.
+ node.setAttribute('fill', this.foreground);
+ }
+ return info;
+ }
+
+ /**
+ * @override
+ */
+ public setHighlighted(node: HTMLElement) {
+ if (node.tagName === 'svg') {
+ super.setHighlighted(node);
+ }
+ }
+
+ /**
+ * @override
+ */
+ public unhighlightNode(info: Highlight) {
+ const previous = info.node.previousSibling as HTMLElement;
+ if (previous && previous.hasAttribute('sre-highlighter-added')) {
+ info.foreground
+ ? info.node.setAttribute('fill', info.foreground)
+ : info.node.removeAttribute('fill');
+ info.node.parentNode.removeChild(previous);
+ return;
+ }
+ info.node.style.backgroundColor = info.background;
+ info.node.style.color = info.foreground;
+ }
+
+ /**
+ * @override
+ */
+ public isMactionNode(node: HTMLElement) {
+ return node.getAttribute('data-mml-node') === this.mactionName;
+ }
+
+ /**
+ * @override
+ */
+ public getMactionNodes(node: HTMLElement) {
+ return Array.from(
+ node.querySelectorAll(`[data-mml-node="${this.mactionName}"]`)
+ ) as HTMLElement[];
+ }
+}
+
+class ChtmlHighlighter extends AbstractHighlighter {
+ /**
+ * @override
+ */
+ constructor() {
+ super();
+ this.mactionName = 'mjx-maction';
+ }
+
+ /**
+ * @override
+ */
+ public highlightNode(node: HTMLElement) {
+ const info = {
+ node: node,
+ background: node.style.backgroundColor,
+ foreground: node.style.color,
+ };
+ if (!this.isHighlighted(node)) {
+ node.style.backgroundColor = this.background;
+ node.style.color = this.foreground;
+ }
+ return info;
+ }
+
+ /**
+ * @override
+ */
+ public unhighlightNode(info: Highlight) {
+ info.node.style.backgroundColor = info.background;
+ info.node.style.color = info.foreground;
+ }
+
+ /**
+ * @override
+ */
+ public isMactionNode(node: HTMLElement) {
+ return node.tagName?.toUpperCase() === this.mactionName.toUpperCase();
+ }
+
+ /**
+ * @override
+ */
+ public getMactionNodes(node: HTMLElement) {
+ return Array.from(
+ node.getElementsByTagName(this.mactionName)
+ ) as HTMLElement[];
+ }
+}
+
+/**
+ * Highlighter factory that returns the highlighter that goes with the current
+ * Mathjax renderer.
+ *
+ * @param {NamedColor} back A background color specification.
+ * @param {NamedColor} fore A foreground color specification.
+ * @param {string} renderer The renderer name.
+ * @returns {Highlighter} A new highlighter.
+ */
+export function getHighlighter(
+ back: NamedColor,
+ fore: NamedColor,
+ renderer: string
+): Highlighter {
+ const highlighter = new highlighterMapping[renderer]();
+ highlighter.setColor(back, fore);
+ return highlighter;
+}
+
+/**
+ * Mapping renderer names to highlighter constructor.
+ */
+const highlighterMapping: { [key: string]: new () => Highlighter } = {
+ SVG: SvgHighlighter,
+ CHTML: ChtmlHighlighter,
+ generic: ChtmlHighlighter,
+};
diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts
index 3d42e2b3e..943def94d 100644
--- a/ts/a11y/explorer/KeyExplorer.ts
+++ b/ts/a11y/explorer/KeyExplorer.ts
@@ -1,6 +1,6 @@
/*************************************************************
*
- * Copyright (c) 2009-2022 The MathJax Consortium
+ * Copyright (c) 2009-2025 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,446 +15,1719 @@
* limitations under the License.
*/
-
/**
- * @fileoverview Explorers based on keyboard events.
+ * @file Explorers based on keyboard events.
*
* @author v.sorge@mathjax.org (Volker Sorge)
*/
-
-import {A11yDocument, Region} from './Region.js';
-import {Explorer, AbstractExplorer} from './Explorer.js';
-import Sre from '../sre.js';
-
+import { HoverRegion, SpeechRegion, LiveRegion } from './Region.js';
+import { STATE } from '../../core/MathItem.js';
+import type { ExplorerMathItem, ExplorerMathDocument } from '../explorer.js';
+import { Explorer, AbstractExplorer } from './Explorer.js';
+import { ExplorerPool } from './ExplorerPool.js';
+import { MmlNode } from '../../core/MmlTree/MmlNode.js';
+import { honk, SemAttr } from '../speech/SpeechUtil.js';
+import { GeneratorPool } from '../speech/GeneratorPool.js';
+import { context } from '../../util/context.js';
/**
* Interface for keyboard explorers. Adds the necessary keyboard events.
+ *
* @interface
- * @extends {Explorer}
+ * @augments {Explorer}
*/
export interface KeyExplorer extends Explorer {
-
/**
* Function to be executed on key down.
+ *
* @param {KeyboardEvent} event The keyboard event.
*/
KeyDown(event: KeyboardEvent): void;
/**
* Function to be executed on focus in.
+ *
* @param {KeyboardEvent} event The keyboard event.
*/
FocusIn(event: FocusEvent): void;
/**
* Function to be executed on focus out.
+ *
* @param {KeyboardEvent} event The keyboard event.
*/
FocusOut(event: FocusEvent): void;
+ /**
+ * A method that is executed if no move is executed.
+ */
+ NoMove(): void;
+}
+
+/**********************************************************************/
+
+/**
+ * Type of function that implements a key press action
+ */
+type keyMapping = (
+ explorer: SpeechExplorer,
+ event: KeyboardEvent
+) => boolean | void;
+
+/**
+ * Selectors for walking.
+ */
+const nav = '[data-speech-node]';
+
+/**
+ * Predicate to check if element is a MJX container.
+ *
+ * @param {HTMLElement} el The HTML element.
+ * @returns {boolean} True if the element is an mjx-container.
+ */
+export function isContainer(el: HTMLElement): boolean {
+ return el.matches('mjx-container');
+}
+
+/**
+ * Test if an event has any modifier keys
+ *
+ * @param {MouseEvent|KeyboardEvent} event The event to check
+ * @param {boolean} shift True if shift is to be included in check
+ * @returns {boolean} True if shift, ctrl, alt, or meta key is pressed
+ */
+export function hasModifiers(
+ event: MouseEvent | KeyboardEvent,
+ shift: boolean = true
+): boolean {
+ return (
+ (event.shiftKey && shift) || event.metaKey || event.altKey || event.ctrlKey
+ );
+}
+
+/**********************************************************************/
+
+/**
+ * Creates a customized help dialog
+ *
+ * @param {string} title The title to use for the message
+ * @param {string} select Additional ways to select the typeset math
+ * @returns {string} The customized message
+ */
+function helpMessage(title: string, select: string): string {
+ return `
+Exploring expressions ${title}
+
+The mathematics on this page is being rendered by MathJax, which
+generates both the text spoken by screen readers, as well as the
+visual layout for sighted users.
+
+Expressions typeset by MathJax can be explored interactively, and
+are focusable. You can use the Tab key to move to a typeset
+expression${select}. Initially, the expression will be read in full,
+but you can use the following keys to explore the expression
+further:
+
+
+
+- Down Arrow moves one level deeper into the expression to
+allow you to explore the current subexpression term by term.
+
+- Up Arrow moves back up a level within the expression.
+
+- Right Arrow moves to the next term in the current
+subexpression.
+
+- Left Arrow moves to the next term in the current
+subexpression.
+
+- Shift+Arrow moves to a neighboring cell within a table.
+
+
- 0-9+0-9 jumps to a cell by its index in the table, where 0 = 10.
+
+
- Home takes you to the top of the expression.
+
+- Enter or Return clicks a link or activates an active
+subexpression.
+
+- Space opens the MathJax contextual menu where you can view
+or copy the source format of the expression, or modify MathJax's
+settings.
+
+- Escape exits the expression explorer.
+
+- x gives a summary of the current subexpression.
+
+- z gives the full text of a collapsed expression.
+
+- d gives the current depth within the expression.
+
+- s starts or stops auto-voicing with synchronized highlighting.
+
+- v marks the current position in the expression.
+
+- p cycles through the marked positions in the expression.
+
+- u clears all marked positions and returns to the starting position.
+
+- > cycles through the available speech rule sets
+(MathSpeak, ClearSpeak).
+
+- < cycles through the verbosity levels for the current
+rule set.
+
+- h produces this help listing.
+
+
+The MathJax contextual menu allows you to enable or disable speech
+or Braille generation for mathematical expressions, the language to
+use for the spoken mathematics, and other features of MathJax. In
+particular, the Explorer submenu allows you to specify how the
+mathematics should be identified in the page (e.g., by saying "math"
+when the expression is spoken), and whether or not to include a
+message about the letter "h" bringing up this dialog box.
+
+The contextual menu also provides options for viewing or copying a
+MathML version of the expression or its original source format,
+creating an SVG version of the expression, and viewing various other
+information.
+
+For more help, see the MathJax accessibility documentation.
+`;
}
+/**
+ * Help for the different OS versions
+ */
+const helpData: Map = new Map([
+ [
+ 'MacOS',
+ [
+ 'on MacOS and iOS using VoiceOver',
+ ', or the VoiceOver arrow keys to select an expression',
+ ],
+ ],
+ [
+ 'Windows',
+ [
+ 'in Windows using NVDA or JAWS',
+ `. The screen reader should enter focus or forms mode automatically
+when the expression gets the browser focus, but if not, you can toggle
+focus mode using NVDA+space in NVDA; for JAWS, Enter should start
+forms mode while Numpad Plus leaves it. Also note that you can use
+the NVDA or JAWS key plus the arrow keys to explore the expression
+even in browse mode, and you can use NVDA+shift+arrow keys to
+navigate out of an expression that has the focus in NVDA`,
+ ],
+ ],
+ [
+ 'Unix',
+ [
+ 'in Unix using Orca',
+ `, and Orca should enter focus mode automatically. If not, use the
+Orca+a key to toggle focus mode on or off. Also note that you can use
+Orca+arrow keys to explore expressions even in browse mode`,
+ ],
+ ],
+ ['unknown', ['with a Screen Reader.', '']],
+]);
+
+/**********************************************************************/
+/**********************************************************************/
/**
- * @constructor
- * @extends {AbstractExplorer}
+ * @class
+ * @augments {AbstractExplorer}
*
* @template T The type that is consumed by the Region of this explorer.
*/
-export abstract class AbstractKeyExplorer extends AbstractExplorer implements KeyExplorer {
+export class SpeechExplorer
+ extends AbstractExplorer
+ implements KeyExplorer
+{
+ /*
+ * The explorer key mapping
+ */
+ protected static keyMap: Map = new Map([
+ ['Tab', [() => true]],
+ ['Escape', [(explorer) => explorer.escapeKey()]],
+ ['Enter', [(explorer, event) => explorer.enterKey(event)]],
+ ['Home', [(explorer) => explorer.homeKey()]],
+ [
+ 'ArrowDown',
+ [(explorer, event) => explorer.moveDown(event.shiftKey), true],
+ ],
+ ['ArrowUp', [(explorer, event) => explorer.moveUp(event.shiftKey), true]],
+ [
+ 'ArrowLeft',
+ [(explorer, event) => explorer.moveLeft(event.shiftKey), true],
+ ],
+ [
+ 'ArrowRight',
+ [(explorer, event) => explorer.moveRight(event.shiftKey), true],
+ ],
+ [' ', [(explorer) => explorer.spaceKey()]],
+ ['h', [(explorer) => explorer.hKey()]],
+ ['>', [(explorer) => explorer.nextRules(), false]],
+ ['<', [(explorer) => explorer.nextStyle(), false]],
+ ['x', [(explorer) => explorer.summary(), false]],
+ ['z', [(explorer) => explorer.details(), false]],
+ ['d', [(explorer) => explorer.depth(), false]],
+ ['v', [(explorer) => explorer.addMark(), false]],
+ ['p', [(explorer) => explorer.prevMark(), false]],
+ ['u', [(explorer) => explorer.clearMarks(), false]],
+ ['s', [(explorer) => explorer.autoVoice(), false]],
+ ...[...'0123456789'].map((n) => [
+ n,
+ [(explorer: SpeechExplorer) => explorer.numberKey(parseInt(n)), false],
+ ]),
+ ] as [string, [keyMapping, boolean?]][]);
/**
- * Flag indicating if the explorer is attached to an object.
+ * Switches on or off the use of sound on this explorer.
*/
- public attached: boolean = false;
+ public sound: boolean = false;
+
+ /**
+ * Convenience getter for generator pool of the item.
+ *
+ * @returns {GeneratorPool} The item's generator pool.
+ */
+ private get generators(): GeneratorPool {
+ return this.item?.generatorPool;
+ }
+
+ /**
+ * Shorthand for the item's speech ARIA role
+ *
+ * @returns {string} The role
+ */
+ protected get role(): string {
+ return this.item.ariaRole;
+ }
+
+ /**
+ * Shorthand for the item's ARIA role description
+ *
+ * @returns {string} The role description
+ */
+ protected get description(): string {
+ return this.item.roleDescription;
+ }
+
+ /**
+ * Shorthand for the item's "none" indicator
+ *
+ * @returns {string} The string to use for no description
+ */
+ protected get none(): string {
+ return this.item.none;
+ }
+
+ /**
+ * The currently focused element.
+ */
+ protected current: HTMLElement = null;
+
+ /**
+ * The clicked node from a mousedown event
+ */
+ protected clicked: HTMLElement = null;
+
+ /**
+ * Node to focus on when restarted
+ */
+ public refocus: HTMLElement = null;
+
+ /**
+ * True when we are refocusing on the speech node
+ */
+ protected focusSpeech: boolean = false;
+
+ /**
+ * Selector string for re-focusing after re-rendering
+ */
+ public restarted: string = null;
+
+ /**
+ * The transient speech node
+ */
+ protected speech: HTMLElement = null;
+
+ /**
+ * Set to 'd' when depth is showing, 'x' when summary, '' when speech.
+ */
+ protected speechType: string = '';
/**
- * The attached Sre walker.
- * @type {Walker}
+ * The speech node when the top-level node has no role
*/
- protected walker: Sre.walker;
+ protected img: HTMLElement = null;
+ /**
+ * True when explorer is attached to a node
+ */
+ public attached: boolean = false;
+
+ /**
+ * Treu if events of the explorer are attached.
+ */
private eventsAttached: boolean = false;
/**
- * @override
+ * The array of saved positions.
*/
- protected events: [string, (x: Event) => void][] =
- super.Events().concat(
- [['keydown', this.KeyDown.bind(this)],
- ['focusin', this.FocusIn.bind(this)],
- ['focusout', this.FocusOut.bind(this)]]);
+ protected marks: HTMLElement[] = [];
/**
- * The original tabindex value before explorer was attached.
- * @type {boolean}
+ * The index of the current position in the array.
+ */
+ protected currentMark: number = -1;
+
+ /**
+ * The last explored position from previously exploring this
+ * expression.
+ */
+ protected lastMark: HTMLElement = null;
+
+ /**
+ * First index of cell to jump to
+ */
+ protected pendingIndex: number[] = [];
+
+ /**
+ * The possible types for a "table" cell
+ */
+ protected cellTypes: string[] = ['cell', 'line'];
+
+ /********************************************************************/
+ /*
+ * The event handlers
*/
- private oldIndex: number = null;
/**
* @override
*/
- public abstract KeyDown(event: KeyboardEvent): void;
+ protected events: [string, (x: Event) => void][] = super.Events().concat([
+ ['focusin', this.FocusIn.bind(this)],
+ ['focusout', this.FocusOut.bind(this)],
+ ['keydown', this.KeyDown.bind(this)],
+ ['mousedown', this.MouseDown.bind(this)],
+ ['click', this.Click.bind(this)],
+ ['dblclick', this.DblClick.bind(this)],
+ ]);
/**
* @override
*/
public FocusIn(_event: FocusEvent) {
+ if (this.item.outputData.nofocus) {
+ //
+ // we are refocusing after a menu or dialog box has closed
+ //
+ this.item.outputData.nofocus = false;
+ return;
+ }
+ if (!this.clicked) {
+ this.Start();
+ }
+ this.clicked = null;
}
/**
* @override
*/
public FocusOut(_event: FocusEvent) {
- this.Stop();
+ if (this.current && !this.focusSpeech) {
+ this.setCurrent(null);
+ this.Stop();
+ if (!document.hasFocus()) {
+ this.focusTop();
+ }
+ }
}
/**
* @override
*/
- public Update(force: boolean = false) {
- if (!this.active && !force) return;
- this.highlighter.unhighlight();
- let nodes = this.walker.getFocus(true).getNodes();
- if (!nodes.length) {
- this.walker.refocus();
- nodes = this.walker.getFocus().getNodes();
+ public KeyDown(event: KeyboardEvent) {
+ this.pendingIndex.shift();
+ this.region.cancelVoice();
+ //
+ if (hasModifiers(event, false)) return;
+ //
+ // Get the key action, if there is one and perform it
+ //
+ const CLASS = this.constructor as typeof SpeechExplorer;
+ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
+ const [action, value] = CLASS.keyMap.get(key) || [];
+ const result = action
+ ? value === undefined || this.active
+ ? action(this, event)
+ : value
+ : this.undefinedKey(event);
+ //
+ // If result is true, propagate event,
+ // Otherwise stop the event, and if false, play the honk sound
+ //
+ if (result) return;
+ this.stopEvent(event);
+ if (result === false && this.sound) {
+ this.NoMove();
}
- this.highlighter.highlight(nodes as HTMLElement[]);
}
/**
- * @override
+ * Handle clicks that perform selections, and keep track of clicked node
+ * so that the focusin event will know something was clicked.
+ *
+ * @param {MouseEvent} event The mouse down event
*/
- public Attach() {
- super.Attach();
- this.attached = true;
- this.oldIndex = this.node.tabIndex;
- this.node.tabIndex = 1;
- this.node.setAttribute('role', 'application');
+ private MouseDown(event: MouseEvent) {
+ this.pendingIndex = [];
+ this.region.cancelVoice();
+ //
+ if (hasModifiers(event) || event.buttons === 2) {
+ this.item.outputData.nofocus = true;
+ return;
+ }
+ //
+ // Get the speech element that was clicked
+ //
+ const clicked = this.findClicked(
+ event.target as HTMLElement,
+ event.x,
+ event.y
+ );
+ //
+ // If it is the info icon, top the event and let the click handler process it
+ //
+ if (clicked === this.document.infoIcon) {
+ this.stopEvent(event);
+ return;
+ }
+ //
+ // Remove any selection ranges and
+ // If the target is the highlight rectangle, refocus on the clicked element
+ // otherwise record the click for the focusin handler
+ //
+ document.getSelection()?.removeAllRanges();
+ if ((event.target as HTMLElement).getAttribute('sre-highlighter-added')) {
+ this.refocus = clicked;
+ } else {
+ this.clicked = clicked;
+ }
}
/**
- * @override
+ * Handle a click event
+ *
+ * @param {MouseEvent} event The mouse click event
*/
- public AddEvents() {
- if (!this.eventsAttached) {
- super.AddEvents();
- this.eventsAttached = true;
+ public Click(event: MouseEvent) {
+ //
+ // If we are extending a click region, focus out
+ //
+ if (
+ hasModifiers(event) ||
+ event.buttons === 2 ||
+ document.getSelection().type === 'Range'
+ ) {
+ this.FocusOut(null);
+ return;
+ }
+ //
+ // Get the speech element that was clicked
+ //
+ const clicked = this.findClicked(
+ event.target as HTMLElement,
+ event.x,
+ event.y
+ );
+ //
+ // If it was the info icon, open the help dialog
+ //
+ if (clicked === this.document.infoIcon) {
+ this.stopEvent(event);
+ this.help();
+ return;
+ }
+ //
+ // If the node contains the clicked element,
+ // don't propagate the event
+ // focus on the clicked element when focusin occurs
+ // start the explorer if this isn't a link
+ //
+ if (!clicked || this.node.contains(clicked)) {
+ this.stopEvent(event);
+ this.refocus = clicked;
+ if (!this.triggerLinkMouse()) {
+ this.Start();
+ }
}
}
/**
- * @override
+ * Handle a double-click event (focus full expression)
+ *
+ * @param {MouseEvent} event The mouse click event
*/
- public Detach() {
- if (this.active) {
- this.node.tabIndex = this.oldIndex;
- this.oldIndex = null;
- this.node.removeAttribute('role');
+ public DblClick(event: MouseEvent) {
+ const direction = (document.getSelection() as any).direction ?? 'none';
+ if (hasModifiers(event) || event.buttons === 2 || direction !== 'none') {
+ this.FocusOut(null);
+ } else {
+ this.stopEvent(event);
+ this.refocus = this.rootNode();
+ this.Start();
}
- this.attached = false;
+ }
+
+ /********************************************************************/
+ /*
+ * The Key action functions
+ */
+
+ /**
+ * The space key opens the menu, so it propagates, but we retain the
+ * current focus to refocus it when the menu closes.
+ *
+ * @returns {boolean} Don't cancel the event
+ */
+ protected spaceKey(): boolean {
+ this.refocus = this.current;
+ return true;
}
/**
- * @override
+ * Open the help dialog, and refocus when it closes.
*/
- public Stop() {
+ protected hKey() {
+ this.refocus = this.current;
+ this.help();
+ }
+
+ /**
+ * Stop exploring and focus the top element
+ *
+ * @returns {boolean} Don't cancel the event
+ */
+ protected escapeKey(): boolean {
+ this.Stop();
+ this.focusTop();
+ return true;
+ }
+
+ /**
+ * Process Enter key events
+ *
+ * @param {KeyboardEvent} event The event for the enter key
+ * @returns {void | boolean} False means play the honk sound
+ */
+ protected enterKey(event: KeyboardEvent): void | boolean {
if (this.active) {
- this.highlighter.unhighlight();
- this.walker.deactivate();
+ if (this.triggerLinkKeyboard(event)) {
+ this.Stop();
+ } else {
+ const expandable = this.actionable(this.current);
+ if (!expandable) {
+ return false;
+ }
+ this.refocus = expandable;
+ expandable.dispatchEvent(new Event('click'));
+ }
+ } else {
+ this.Start();
}
- super.Stop();
}
-}
+ /**
+ * Select top-level of expression
+ */
+ protected homeKey() {
+ this.setCurrent(this.rootNode());
+ }
+ /**
+ * Move to deeper level in the expression
+ *
+ * @param {boolean} shift True if shift is pressed
+ * @returns {boolean | void} False if no node, void otherwise
+ */
+ protected moveDown(shift: boolean): boolean | void {
+ return shift
+ ? this.moveToNeighborCell(1, 0)
+ : this.moveTo(this.firstNode(this.current));
+ }
-/**
- * Explorer that pushes speech to live region.
- * @constructor
- * @extends {AbstractKeyExplorer}
- */
-export class SpeechExplorer extends AbstractKeyExplorer {
+ /**
+ * Move to higher level in expression
+ *
+ * @param {boolean} shift True if shift is pressed
+ * @returns {boolean | void} False if no node, void otherwise
+ */
+ protected moveUp(shift: boolean): boolean | void {
+ return shift
+ ? this.moveToNeighborCell(-1, 0)
+ : this.moveTo(this.getParent(this.current));
+ }
- private static updatePromise = Promise.resolve();
+ /**
+ * Move to next term in the expression
+ *
+ * @param {boolean} shift True if shift is pressed
+ * @returns {boolean | void} False if no node, void otherwise
+ */
+ protected moveRight(shift: boolean): boolean | void {
+ return shift
+ ? this.moveToNeighborCell(0, 1)
+ : this.moveTo(this.nextSibling(this.current));
+ }
/**
- * The Sre speech generator associated with the walker.
- * @type {SpeechGenerator}
+ * Move to previous term in the expression
+ *
+ * @param {boolean} shift True if shift is pressed
+ * @returns {boolean | void} False if no node, void otherwise
*/
- public speechGenerator: Sre.speechGenerator;
+ protected moveLeft(shift: boolean): boolean | void {
+ return shift
+ ? this.moveToNeighborCell(0, -1)
+ : this.moveTo(this.prevSibling(this.current));
+ }
/**
- * The name of the option used to control when this is being shown
- * @type {string}
+ * Move to a specified node, unless it is null
+ *
+ * @param {HTMLElement} node The node to move it
+ * @returns {boolean | void} False if no node, void otherwise
*/
- public showRegion: string = 'subtitles';
+ protected moveTo(node: HTMLElement): void | boolean {
+ if (!node) return false;
+ this.setCurrent(node);
+ }
- private init: boolean = false;
+ /**
+ * Move to an adjacent table cell
+ *
+ * @param {number} di Change in row number
+ * @param {number} dj Change in column number
+ * @returns {boolean | void} False if no such cell, void otherwise
+ */
+ protected moveToNeighborCell(di: number, dj: number): boolean | void {
+ const cell = this.tableCell(this.current);
+ if (!cell) return false;
+ const [i, j] = this.cellPosition(cell);
+ if (i == null) return false;
+ const move = this.cellAt(this.cellTable(cell), i + di, j + dj);
+ if (!move) return false;
+ this.setCurrent(move);
+ }
/**
- * 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}
+ * Determine if an event that is not otherwise mapped should be
+ * allowed to propagate.
+ *
+ * @param {KeyboardEvent} event The event to check
+ * @returns {boolean} True if not active or the event has a modifier
*/
- private restarted: boolean = false;
+ protected undefinedKey(event: KeyboardEvent): boolean {
+ return !this.active || hasModifiers(event);
+ }
/**
- * @constructor
- * @extends {AbstractKeyExplorer}
+ * Mark a location so we can return to it later
*/
- constructor(public document: A11yDocument,
- protected region: Region,
- protected node: HTMLElement,
- private mml: string) {
- super(document, region, node);
- this.initWalker();
+ protected addMark() {
+ if (this.current === this.marks[this.marks.length - 1]) {
+ this.setCurrent(this.current);
+ } else {
+ this.currentMark = this.marks.length - 1;
+ this.marks.push(this.current);
+ this.speak('Position marked');
+ }
}
+ /**
+ * Return to a previous location (loop through them).
+ * If no saved marks, go to the last previous position,
+ * or if not, the top level.
+ */
+ protected prevMark() {
+ if (this.currentMark < 0) {
+ if (this.marks.length === 0) {
+ this.setCurrent(this.lastMark || this.rootNode());
+ return;
+ }
+ this.currentMark = this.marks.length - 1;
+ }
+ const current = this.currentMark;
+ this.setCurrent(this.marks[current]);
+ this.currentMark = current - 1;
+ }
/**
- * @override
+ * Clear all saved positions and return to the last explored position.
*/
- public Start() {
- if (!this.attached) return;
- 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);
- this.Start();
- });
- })
- .catch((error: Error) => console.log(error.message));
- return;
+ protected clearMarks() {
+ this.marks = [];
+ this.currentMark = -1;
+ this.prevMark();
+ }
+
+ /**
+ * Toggle auto voicing.
+ */
+ protected autoVoice() {
+ const value = !this.document.options.a11y.voicing;
+ if (this.document.menu) {
+ this.document.menu.menu.pool.lookup('voicing').setValue(value);
+ } else {
+ this.document.options.a11y.voicing = value;
}
- super.Start();
- 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();
this.Update();
- if (this.document.options.a11y[this.showRegion]) {
- SpeechExplorer.updatePromise.then(
- () => this.region.Show(this.node, this.highlighter));
+ }
+
+ /**
+ * Get index for cell to jump to.
+ *
+ * @param {number} n The number key that was pressed
+ * @returns {boolean|void} False if not in a table or no such cell to jump to.
+ */
+ protected numberKey(n: number): boolean | void {
+ if (!this.tableCell(this.current)) return false;
+ if (n === 0) {
+ n = 10;
+ }
+ if (this.pendingIndex.length) {
+ const table = this.cellTable(this.tableCell(this.current));
+ const cell = this.cellAt(table, this.pendingIndex[0] - 1, n - 1);
+ this.pendingIndex = [];
+ this.speak(String(n));
+ if (!cell) return false;
+ setTimeout(() => this.setCurrent(cell), 500);
+ } else {
+ this.pendingIndex = [null, n];
+ this.speak(`Jump to row ${n} and column`);
}
- this.restarted = true;
}
+ /**
+ * Computes the nesting depth announcement for the currently focused sub
+ * expression.
+ */
+ public depth() {
+ if (this.speechType === 'd') {
+ this.setCurrent(this.current);
+ return;
+ }
+ this.speechType = 'd';
+ const parts = [
+ [
+ this.node.getAttribute('data-semantic-level') ?? 'Level',
+ this.current.getAttribute('aria-level') ?? '0',
+ ]
+ .join(' ')
+ .trim(),
+ ];
+ const action = this.actionable(this.current);
+ if (action) {
+ parts.unshift(
+ this.node.getAttribute(
+ action.getAttribute('toggle') === '1'
+ ? 'data-semantic-expandable'
+ : 'data-semantic-collapsible'
+ ) ?? ''
+ );
+ }
+ this.speak(parts.join(' '), this.current.getAttribute(SemAttr.BRAILLE));
+ }
/**
- * @override
+ * Computes the summary for this expression.
*/
- public Update(force: boolean = false) {
- super.Update(force);
- 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;
- }
- SpeechExplorer.updatePromise = SpeechExplorer.updatePromise.then(async () => {
- return Sre.sreReady()
- .then(() => Sre.setupEngine({modality: options.modality,
- locale: options.locale}))
- .then(() => this.region.Update(this.walker.speech()));
- });
+ public summary() {
+ if (this.speechType === 'x') {
+ this.setCurrent(this.current);
+ return;
+ }
+ this.speechType = 'x';
+ const summary = this.current.getAttribute(SemAttr.SUMMARY);
+ this.speak(
+ summary,
+ this.current.getAttribute(SemAttr.BRAILLE),
+ this.SsmlAttributes(this.current, SemAttr.SUMMARY_SSML)
+ );
+ }
+
+ /**
+ * Cycles to next speech rule set if possible and recomputes the speech for
+ * the expression.
+ */
+ public nextRules() {
+ this.node.removeAttribute('data-speech-attached');
+ this.restartAfter(this.generators.nextRules(this.item));
}
+ /**
+ * Cycles to next speech style or preference if possible and recomputes the
+ * speech for the expression.
+ */
+ public nextStyle() {
+ this.node.removeAttribute('data-speech-attached');
+ this.restartAfter(this.generators.nextStyle(this.current, this.item));
+ }
/**
- * Computes the speech for the current expression once Sre is ready.
- * @param {Walker} walker The sre walker.
+ * Speak the expanded version of a collapsed expression.
*/
- public Speech(walker: Sre.walker) {
- SpeechExplorer.updatePromise.then(() => {
- walker.speech();
- this.node.setAttribute('hasspeech', 'true');
- this.Update();
- if (this.restarted && this.document.options.a11y[this.showRegion]) {
- this.region.Show(this.node, this.highlighter);
+ public details() {
+ //
+ // If the current node is not collapsible and collapsed, just speak it
+ //
+ const action = this.actionable(this.current);
+ if (
+ !action ||
+ !action.getAttribute('data-collapsible') ||
+ action.getAttribute('toggle') !== '1' ||
+ this.speechType === 'z'
+ ) {
+ this.setCurrent(this.current);
+ return;
+ }
+ this.speechType = 'z';
+ //
+ // Otherwise, look for the current node in the MathML tree
+ //
+ const id = this.nodeId(this.current);
+ let current: MmlNode;
+ this.item.root.walkTree((node) => {
+ if (node.attributes.get('data-semantic-id') === id) {
+ current = node;
}
});
+ //
+ // Create a new MathML string from the subtree
+ //
+ let mml = this.item.toMathML(current, this.item);
+ if (!current.isKind('math')) {
+ mml = ``;
+ }
+ mml = mml.replace(
+ / (?:data-semantic-|aria-|data-speech-|data-latex).*?=".*?"/g,
+ ''
+ );
+ //
+ // Get the speech for the new subtree and speak it.
+ //
+ this.item
+ .speechFor(mml)
+ .then(([speech, braille]) => this.speak(speech, braille));
}
-
/**
- * @override
+ * Displays the help dialog.
*/
- public KeyDown(event: KeyboardEvent) {
- const code = event.keyCode;
- this.walker.modifier = event.shiftKey;
- if (code === 27) {
- this.Stop();
+ protected help() {
+ const adaptor = this.document.adaptor;
+ const helpBackground = adaptor.node('mjx-help-background');
+ const close = (event: Event) => {
+ helpBackground.remove();
+ this.node.focus();
this.stopEvent(event);
- return;
+ };
+ helpBackground.addEventListener('click', close);
+ const helpSizer = adaptor.node('mjx-help-sizer', {}, [
+ adaptor.node(
+ 'mjx-help-dialog',
+ { tabindex: 0, role: 'dialog', 'aria-labeledby': 'mjx-help-label' },
+ [
+ adaptor.node('h1', { id: 'mjx-help-label' }, [
+ adaptor.text('MathJax Expression Explorer Help'),
+ ]),
+ adaptor.node('div'),
+ adaptor.node('input', { type: 'button', value: 'Close' }),
+ ]
+ ),
+ ]);
+ helpBackground.append(helpSizer);
+ const help = helpSizer.firstChild as HTMLElement;
+ help.addEventListener('click', (event) => this.stopEvent(event));
+ help.lastChild.addEventListener('click', close);
+ help.addEventListener('keydown', (event: KeyboardEvent) => {
+ if (event.code === 'Escape') {
+ close(event);
+ }
+ });
+ const [title, select] = helpData.get(context.os);
+ (help.childNodes[1] as HTMLElement).innerHTML = helpMessage(title, select);
+ document.body.append(helpBackground);
+ help.focus();
+ }
+
+ /********************************************************************/
+ /*
+ * Methods to handle the currently selected node and its speech
+ */
+
+ /**
+ * Set the currently selected node and speak its label, if requested.
+ *
+ * @param {HTMLElement} node The node that should become current
+ * @param {boolean} addDescription True if the speech node should get a description
+ */
+ protected setCurrent(node: HTMLElement, addDescription: boolean = false) {
+ this.speechType = '';
+ if (!document.hasFocus()) {
+ this.refocus = this.current;
}
- if (this.active) {
- this.Move(code);
- if (this.triggerLink(code)) return;
- this.stopEvent(event);
- return;
+ //
+ // Let AT know we are making changes
+ //
+ this.node.setAttribute('aria-busy', 'true');
+ //
+ // If there is a current selection
+ // clear it and remove the associated speech
+ // if we aren't setting a new selection
+ // (i.e., we are focusing out)
+ //
+ if (this.current) {
+ for (const part of this.getSplitNodes(this.current)) {
+ part.classList.remove('mjx-selected');
+ }
+ this.pool.unhighlight();
+ if (this.document.options.a11y.tabSelects === 'last') {
+ this.refocus = this.current;
+ }
+ if (!node) {
+ this.lastMark = this.current;
+ this.removeSpeech();
+ }
+ this.current = null;
}
- if (code === 32 && event.shiftKey || code === 13) {
- this.Start();
- this.stopEvent(event);
+ //
+ // If there is a current node
+ // Select it and add its speech, if requested
+ //
+ this.current = node;
+ this.currentMark = -1;
+ if (this.current) {
+ const parts = this.getSplitNodes(this.current);
+ for (const part of parts) {
+ part.classList.add('mjx-selected');
+ }
+ this.pool.highlight(parts);
+ this.addSpeech(node, addDescription);
}
+ //
+ // Done making changes
+ //
+ this.node.removeAttribute('aria-busy');
}
/**
- * Programmatically triggers a link if the focused node contains one.
- * @param {number} code The keycode of the last key pressed.
+ * Get all nodes with the same semantic id (multiple nodes if there are line breaks).
+ *
+ * @param {HTMLElement} node The node to check if it is split
+ * @returns {HTMLElement[]} All the nodes for the given id
*/
- protected triggerLink(code: number) {
- if (code !== 13) {
- return false;
+ protected getSplitNodes(node: HTMLElement): HTMLElement[] {
+ const id = this.nodeId(node);
+ if (!id) {
+ return [node];
}
- let node = this.walker.getFocus().getNodes()?.[0];
- let focus = node?.
- getAttribute('data-semantic-postfix')?.
- match(/(^| )link($| )/);
- if (focus) {
- node.parentNode.dispatchEvent(new MouseEvent('click'));
- return true;
+ return Array.from(this.node.querySelectorAll(`[data-semantic-id="${id}"]`));
+ }
+
+ /**
+ * Remove the top-level speech node and create
+ * a temporary one for the given node.
+ *
+ * @param {HTMLElement} node The node to be spoken
+ * @param {boolean} describe True if the description should be added
+ */
+ protected addSpeech(node: HTMLElement, describe: boolean) {
+ this.img?.remove();
+ let speech = [
+ node.getAttribute(SemAttr.PREFIX),
+ node.getAttribute(SemAttr.SPEECH),
+ node.getAttribute(SemAttr.POSTFIX),
+ ]
+ .join(' ')
+ .trim();
+ if (describe) {
+ let description =
+ this.description === this.none ? '' : ', ' + this.description;
+ if (this.document.options.a11y.help) {
+ description += ', press h for help';
+ }
+ speech += description;
}
- return false;
+ this.speak(
+ speech,
+ node.getAttribute(SemAttr.BRAILLE),
+ this.SsmlAttributes(node, SemAttr.SPEECH_SSML)
+ );
+ this.node.setAttribute('tabindex', '-1');
}
/**
- * @override
+ * If there is a speech node, remove it
+ * and put back the top-level node, if needed.
+ */
+ protected removeSpeech() {
+ if (this.speech) {
+ this.speech.remove();
+ this.speech = null;
+ if (this.img) {
+ this.node.append(this.img);
+ }
+ this.node.setAttribute('tabindex', '0');
+ }
+ }
+
+ /**
+ * Create a new speech node and sets its needed attributes,
+ * then add it to the container and focus it. If there is
+ * and old speech node, remove it after a delay (the delay
+ * is needed for Orca on Linux).
+ *
+ * @param {string} speech The string to speak
+ * @param {string} braille The braille string
+ * @param {string[]} ssml The SSML attributes to add
+ * @param {string} description The description to add to the speech
*/
- public Move(key: number) {
- this.walker.move(key);
+ public speak(
+ speech: string,
+ braille: string = '',
+ ssml: string[] = null,
+ description: string = this.none
+ ) {
+ const oldspeech = this.speech;
+ this.speech = document.createElement('mjx-speech');
+ this.speech.setAttribute('role', this.role);
+ this.speech.setAttribute('aria-label', speech);
+ this.speech.setAttribute(SemAttr.SPEECH, speech);
+ if (ssml) {
+ this.speech.setAttribute(SemAttr.PREFIX_SSML, ssml[0] || '');
+ this.speech.setAttribute(SemAttr.SPEECH_SSML, ssml[1] || '');
+ this.speech.setAttribute(SemAttr.POSTFIX_SSML, ssml[2] || '');
+ }
+ if (braille) {
+ this.speech.setAttribute('aria-braillelabel', braille);
+ }
+ this.speech.setAttribute('aria-roledescription', description);
+ this.speech.setAttribute('tabindex', '0');
+ this.node.append(this.speech);
+ this.focusSpeech = true;
+ this.speech.focus();
+ this.focusSpeech = false;
this.Update();
+ if (oldspeech) {
+ setTimeout(() => oldspeech.remove(), 100);
+ }
}
/**
- * Initialises the Sre walker.
+ * Set up the MathItem output to handle the speech exploration
*/
- private initWalker() {
- this.speechGenerator = Sre.getSpeechGenerator('Tree');
- let dummy = Sre.getWalker(
- 'dummy', this.node, this.speechGenerator, this.highlighter, this.mml);
- this.walker = dummy;
+ public attachSpeech() {
+ const item = this.item;
+ const container = this.node;
+ if (!container.hasAttribute('has-speech')) {
+ for (const child of Array.from(container.childNodes) as HTMLElement[]) {
+ child.setAttribute('aria-hidden', 'true'); // hide the content
+ }
+ container.setAttribute('has-speech', 'true');
+ }
+ const description = item.roleDescription;
+ const speech =
+ (container.getAttribute(SemAttr.SPEECH) || '') +
+ (description ? ', ' + description : '');
+ this.img?.remove();
+ this.img = this.document.adaptor.node('mjx-speech', {
+ 'aria-label': speech,
+ role: 'img',
+ 'aria-roledescription': item.none,
+ });
+ container.appendChild(this.img);
}
/**
- * Retrieves the speech options to sync with document options.
- * @return {{[key: string]: string}} The options settings for the speech
- * generator.
+ * Undo any changes from attachSpeech()
*/
- private 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);
+ public detachSpeech() {
+ const container = this.node;
+ this.img?.remove();
+ container.removeAttribute('has-speech');
+ for (const child of Array.from(container.childNodes) as HTMLElement[]) {
+ child.removeAttribute('aria-hidden');
}
- return options;
}
-}
+ /**
+ * Set focus on the current node
+ */
+ public focus() {
+ this.node.focus();
+ }
+ /********************************************************************/
+ /*
+ * Utility functions
+ */
-/**
- * Explorer that magnifies what is currently explored. Uses a hover region.
- * @constructor
- * @extends {AbstractKeyExplorer}
- */
-export class Magnifier extends AbstractKeyExplorer {
+ /**
+ * @param {HTMLElement} node The node whose ID we want
+ * @returns {string} The node's semantic ID
+ */
+ protected nodeId(node: HTMLElement): string {
+ return node.getAttribute('data-semantic-id');
+ }
/**
- * @constructor
- * @extends {AbstractKeyExplorer}
+ * @param {HTMLElement} node The node whose parent ID we want
+ * @returns {string} The node's parent's semantic ID
*/
- constructor(public document: A11yDocument,
- protected region: Region,
- protected node: HTMLElement,
- private mml: string) {
- super(document, region, node);
- this.walker = Sre.getWalker(
- 'table', this.node, Sre.getSpeechGenerator('Dummy'),
- this.highlighter, this.mml);
+ protected parentId(node: HTMLElement): string {
+ return node.getAttribute('data-semantic-parent');
}
/**
- * @override
+ * @param {string} id The semantic ID of the node we want
+ * @returns {HTMLElement} The HTML node with that id
+ */
+ protected getNode(id: string): HTMLElement {
+ return id ? this.node.querySelector(`[data-semantic-id="${id}"]`) : null;
+ }
+
+ /**
+ * @param {HTMLElement} node The HTML node whose parent is to be found
+ * @returns {HTMLElement} The HTML node of the parent node
+ */
+ protected getParent(node: HTMLElement): HTMLElement {
+ return this.getNode(this.parentId(node));
+ }
+
+ /**
+ * @param {HTMLElement} node The node whose child array we want
+ * @returns {string[]} The array of semantic IDs of its children
+ */
+ protected childArray(node: HTMLElement): string[] {
+ return node ? node.getAttribute('data-semantic-children').split(/,/) : [];
+ }
+
+ /**
+ * @param {HTMLElement} node The node to check for being a cell node
+ * @returns {boolean} True if the node is a cell node
+ */
+ protected isCell(node: HTMLElement): boolean {
+ return (
+ !!node && this.cellTypes.includes(node.getAttribute('data-semantic-type'))
+ );
+ }
+
+ /**
+ * @param {HTMLElement} node The node to check for being a row node
+ * @returns {boolean} True if the node is a row node
+ */
+ protected isRow(node: HTMLElement): boolean {
+ return !!node && node.getAttribute('data-semantic-type') === 'row';
+ }
+
+ /**
+ * @param {HTMLElement} node A node that may be in a table cell
+ * @returns {HTMLElement} The HTML node for the table cell containing it, or null
*/
- public Update(force: boolean = false) {
- super.Update(force);
- this.showFocus();
+ protected tableCell(node: HTMLElement): HTMLElement {
+ while (node && node !== this.node) {
+ if (this.isCell(node)) {
+ return node;
+ }
+ node = node.parentNode as HTMLElement;
+ }
+ return null;
+ }
+
+ /**
+ * @param {HTMLElement} cell An HTML node that is a cell of a table
+ * @returns {HTMLElement} The HTML node for semantic table element containing the cell
+ */
+ protected cellTable(cell: HTMLElement): HTMLElement {
+ const row = this.getParent(cell);
+ return this.isRow(row) ? this.getParent(row) : row;
+ }
+
+ /**
+ * @param {HTMLElement} cell The HTML node for a semantic table cell
+ * @returns {[number, number]} The row and column numbers for the cell in its table (0-based)
+ */
+ protected cellPosition(cell: HTMLElement): [number, number] {
+ const row = this.getParent(cell);
+ const j = this.childArray(row).indexOf(this.nodeId(cell));
+ if (!this.isRow(row)) {
+ return [j, 1];
+ }
+ const table = this.getParent(row);
+ const i = this.childArray(table).indexOf(this.nodeId(row));
+ return [i, j];
+ }
+
+ /**
+ * @param {HTMLElement} table An HTML node for a semantic table element
+ * @param {number} i The row number of the desired cell in the table
+ * @param {number} j The column numnber of the desired cell in the table
+ * @returns {HTMLElement} The HTML element for the (i,j)-th cell of the table
+ */
+ protected cellAt(table: HTMLElement, i: number, j: number): HTMLElement {
+ const row = this.getNode(this.childArray(table)[i]);
+ if (!this.isRow(row)) {
+ return j === 1 ? row : null;
+ }
+ const cell = this.getNode(this.childArray(row)[j]);
+ return cell;
+ }
+
+ /**
+ * Get an element's first speech child. This is computed by going through the
+ * owns list until the first speech element is found.
+ *
+ * @param {HTMLElement} node The parent element to get a child from
+ * @returns {HTMLElement} The first speech child of the node
+ */
+ protected firstNode(node: HTMLElement): HTMLElement {
+ const owns = node.getAttribute('data-semantic-owns');
+ if (!owns) {
+ return node.querySelector(nav) as HTMLElement;
+ }
+ const ownsList = owns.split(/ /);
+ for (const id of ownsList) {
+ const node = this.getNode(id);
+ if (node?.hasAttribute('data-speech-node')) {
+ return node;
+ }
+ }
+ return node.querySelector(nav) as HTMLElement;
}
+ /**
+ * Get the element's semantic root node. We compute this from the root id
+ * given in the semantic structure. The semantic structure is an sexp either
+ * of the form `0` or `(0 1 (2 ...) ...)`. We can safely assume that the root
+ * node contains the speech for the entire structure.
+ *
+ * If for some reason the semantic structure is not available, we return the
+ * first speech node found in the expression.
+ *
+ * @returns {HTMLElement} The semantic root or first speech node.
+ */
+ protected rootNode(): HTMLElement {
+ const base = this.node.querySelector('[data-semantic-structure]');
+ if (!base) {
+ return this.node.querySelector(nav) as HTMLElement;
+ }
+ const id = base
+ .getAttribute('data-semantic-structure')
+ .split(/ /)[0]
+ .replace('(', '');
+ return this.getNode(id);
+ }
+
+ /**
+ * Navigate one step to the right on the same level.
+ *
+ * @param {HTMLElement} node The current element.
+ * @returns {HTMLElement} The next element.
+ */
+ protected nextSibling(node: HTMLElement): HTMLElement {
+ const id = this.parentId(node);
+ if (!id) return null;
+ const owns = this.getNode(id)
+ .getAttribute('data-semantic-owns')
+ ?.split(/ /);
+ if (!owns) return null;
+ let i = owns.indexOf(this.nodeId(node));
+ let next;
+ do {
+ next = this.getNode(owns[++i]);
+ } while (next && !next.hasAttribute('data-speech-node'));
+ return next;
+ }
+
+ /**
+ * Navigate one step to the left on the same level.
+ *
+ * @param {HTMLElement} node The current element.
+ * @returns {HTMLElement} The next element.
+ */
+ protected prevSibling(node: HTMLElement): HTMLElement {
+ const id = this.parentId(node);
+ if (!id) return null;
+ const owns = this.getNode(id)
+ .getAttribute('data-semantic-owns')
+ ?.split(/ /);
+ if (!owns) return null;
+ let i = owns.indexOf(this.nodeId(node));
+ let prev;
+ do {
+ prev = this.getNode(owns[--i]);
+ } while (prev && !prev.hasAttribute('data-speech-node'));
+ return prev;
+ }
+
+ /**
+ * Find the speech node that was clicked, if any
+ *
+ * @param {HTMLElement} node The target node that was clicked
+ * @param {number} x The x-coordinate of the click
+ * @param {number} y The y-coordinate of the click
+ * @returns {HTMLElement} The clicked node or null
+ */
+ protected findClicked(node: HTMLElement, x: number, y: number): HTMLElement {
+ //
+ // Check if the click is on the info icon and return that if it is.
+ //
+ const icon = this.document.infoIcon;
+ if (icon === node || icon.contains(node)) {
+ return icon;
+ }
+ //
+ // For CHTML, get the closest navigable parent element.
+ //
+ if (this.node.getAttribute('jax') !== 'SVG') {
+ return node.closest(nav) as HTMLElement;
+ }
+ //
+ // For SVG, look through the tree to find the element whose bounding box
+ // contains the click (x,y) position.
+ //
+ let found = null;
+ let clicked = this.node;
+ while (clicked) {
+ if (clicked.matches(nav)) {
+ found = clicked; // could be this node, but check if a child is clicked
+ }
+ const nodes = Array.from(clicked.childNodes) as HTMLElement[];
+ clicked = null;
+ for (const child of nodes) {
+ if (
+ child !== this.speech &&
+ child !== this.img &&
+ child.tagName.toLowerCase() !== 'rect'
+ ) {
+ const { left, right, top, bottom } = child.getBoundingClientRect();
+ if (left <= x && x <= right && top <= y && y <= bottom) {
+ clicked = child;
+ break;
+ }
+ }
+ }
+ }
+ return found;
+ }
+
+ /**
+ * Focus the container node without activating it (e.g., when Escape is pressed)
+ */
+ protected focusTop() {
+ this.focusSpeech = true;
+ this.node.focus();
+ this.focusSpeech = false;
+ }
+
+ /**
+ * Get the SSML attribute array
+ *
+ * @param {HTMLElement} node The node whose SSML attributes are to be obtained
+ * @param {SemAttr} center The name of the SSML attribute between pre and postfix
+ * @returns {string[]} The prefix/speech or summary/postfix array
+ */
+ protected SsmlAttributes(node: HTMLElement, center: SemAttr): string[] {
+ return [
+ node.getAttribute(SemAttr.PREFIX_SSML),
+ node.getAttribute(center),
+ node.getAttribute(SemAttr.POSTFIX_SSML),
+ ];
+ }
+
+ /**
+ * Restarts the explorer after a promise resolves (e.g., for an maction rerender)
+ *
+ * @param {Promise} promise The promise to restart after
+ */
+ protected async restartAfter(promise: Promise) {
+ await promise;
+ this.attachSpeech();
+ const current = this.current;
+ this.current = null;
+ this.pool.unhighlight();
+ this.setCurrent(current);
+ }
+
+ /********************************************************************/
+ /*
+ * Base class overrides
+ */
+
+ /**
+ * @param {ExplorerMathDocument} document The accessible math document.
+ * @param {ExplorerPool} pool The explorer pool.
+ * @param {SpeechRegion} region The speech region for the explorer.
+ * @param {HTMLElement} node The node the explorer is assigned to.
+ * @param {LiveRegion} brailleRegion The braille region.
+ * @param {HoverRegion} magnifyRegion The magnification region.
+ * @param {MmlNode} _mml The internal math node.
+ * @param {ExplorerMathItem} item The math item.
+ * @class
+ * @augments {AbstractExplorer}
+ */
+ constructor(
+ public document: ExplorerMathDocument,
+ public pool: ExplorerPool,
+ public region: SpeechRegion,
+ protected node: HTMLElement,
+ public brailleRegion: LiveRegion,
+ public magnifyRegion: HoverRegion,
+ _mml: MmlNode,
+ public item: ExplorerMathItem
+ ) {
+ super(document, pool, null, node);
+ }
+
+ /**
+ * Determine the node that should be made active when we start
+ * (the refocus, current, or restarted node, if any otherwise null)
+ *
+ * @returns {HTMLElement} The node to be made the current node
+ */
+ protected findStartNode(): HTMLElement {
+ let node = this.refocus || this.current;
+ if (!node && this.restarted) {
+ node = this.node.querySelector(this.restarted);
+ }
+ this.refocus = this.restarted = null;
+ return node;
+ }
/**
* @override
*/
- public Start() {
+ public async Start() {
+ //
+ // If we aren't attached or already active, return
+ //
+ if (!this.attached || this.active) return;
+ this.document.activeItem = this.item;
+ //
+ // If there is no speech, request the speech and wait for it
+ //
+ if (this.item.state() < STATE.ATTACHSPEECH) {
+ this.item.attachSpeech(this.document);
+ await this.generators.promise;
+ }
+ //
+ // If we are respnding to a focusin on the speech node, we are done
+ //
+ if (this.focusSpeech) return;
+ //
+ // Mark the node as active (for CSS that turns on the info icon)
+ // and add the info icon.
+ //
+ this.node.classList.add('mjx-explorer-active');
+ this.node.append(this.document.infoIcon);
+ //
+ // Get the node to make current, and determine if we need to add a
+ // speech node (or just use the top-level node), then set the
+ // current node (which creates the speech) and start the explorer.
+ //
+ const node = this.findStartNode();
+ this.setCurrent(node || this.rootNode(), !node);
super.Start();
- if (!this.attached) return;
- this.region.Show(this.node, this.highlighter);
- this.walker.activate();
+ //
+ // Show any needed regions
+ //
+ const options = this.document.options;
+ const a11y = options.a11y;
+ if (a11y.subtitles && a11y.speech && options.enableSpeech) {
+ this.region.Show(this.node, this.highlighter);
+ }
+ if (a11y.viewBraille && a11y.braille && options.enableBraille) {
+ this.brailleRegion.Show(this.node, this.highlighter);
+ }
+ if (a11y.keyMagnifier) {
+ this.magnifyRegion.Show(this.current, this.highlighter);
+ }
this.Update();
}
+ /**
+ * @override
+ */
+ public Stop() {
+ if (this.active) {
+ const description = this.description;
+ if (this.node.getAttribute('aria-roledescription') !== description) {
+ this.node.setAttribute('aria-roledescription', description);
+ }
+ this.node.classList.remove('mjx-explorer-active');
+ this.document.infoIcon.remove();
+ this.pool.unhighlight();
+ this.magnifyRegion.Hide();
+ this.region.Hide();
+ this.brailleRegion.Hide();
+ }
+ super.Stop();
+ }
/**
- * Shows the nodes that are currently focused.
+ * @override
*/
- private showFocus() {
- let node = this.walker.getFocus().getNodes()[0] as HTMLElement;
- this.region.Show(node, this.highlighter);
+ public Update() {
+ if (!this.active) return;
+ this.region.node = this.node;
+ this.generators.updateRegions(
+ this.speech || this.node,
+ this.region,
+ this.brailleRegion
+ );
+ this.magnifyRegion.Update(this.current);
}
+ /**
+ * @override
+ */
+ public Attach() {
+ if (this.attached) return;
+ super.Attach();
+ this.node.setAttribute('tabindex', '0');
+ this.attached = true;
+ }
/**
* @override
*/
- public Move(key: number) {
- let result = this.walker.move(key);
- if (result) {
- this.Update();
+ public Detach() {
+ super.RemoveEvents();
+ this.node.removeAttribute('role');
+ this.node.removeAttribute('aria-roledescription');
+ this.node.removeAttribute('aria-label');
+ this.img?.remove();
+ if (this.active) {
+ this.node.setAttribute('tabindex', '0');
}
+ this.attached = false;
}
+ /**
+ * @override
+ */
+ public NoMove() {
+ honk();
+ }
/**
* @override
*/
- public KeyDown(event: KeyboardEvent) {
- const code = event.keyCode;
- this.walker.modifier = event.shiftKey;
- if (code === 27) {
- this.Stop();
- this.stopEvent(event);
- return;
+ public AddEvents() {
+ if (!this.eventsAttached) {
+ super.AddEvents();
+ this.eventsAttached = true;
}
- if (this.active && code !== 13) {
- this.Move(code);
- this.stopEvent(event);
- return;
+ }
+
+ /********************************************************************/
+ /*
+ * Actions and links
+ */
+
+ /**
+ * Checks if a node is actionable, i.e., corresponds to an maction.
+ *
+ * @param {HTMLElement} node The (rendered) node under consideration.
+ * @returns {HTMLElement} The node corresponding to an maction element.
+ */
+ private actionable(node: HTMLElement): HTMLElement {
+ const parent = node?.parentNode as HTMLElement;
+ return parent && this.highlighter.isMactionNode(parent) ? parent : null;
+ }
+
+ /**
+ * Programmatically triggers a link if the focused node contains one.
+ *
+ * @param {KeyboardEvent} event The keyboard event for the last keydown event.
+ * @returns {boolean} True if link was successfully triggered.
+ */
+ protected triggerLinkKeyboard(event: KeyboardEvent): boolean {
+ if (!this.current) {
+ if (event.target instanceof HTMLAnchorElement) {
+ event.target.dispatchEvent(new MouseEvent('click'));
+ return true;
+ }
+ return false;
}
- if (code === 32 && event.shiftKey || code === 13) {
- this.Start();
- this.stopEvent(event);
+ return this.triggerLink(this.current);
+ }
+
+ /**
+ * Executiving the trigger the link action.
+ *
+ * @param {HTMLElement} node The node with the link.
+ * @returns {boolean} True if link was successfully triggered.
+ */
+ protected triggerLink(node: HTMLElement): boolean {
+ const focus = node
+ ?.getAttribute('data-semantic-postfix')
+ ?.match(/(^| )link($| )/);
+ if (focus) {
+ while (node && node !== this.node) {
+ if (node instanceof HTMLAnchorElement) {
+ node.dispatchEvent(new MouseEvent('click'));
+ setTimeout(() => this.FocusOut(null), 50);
+ return true;
+ }
+ node = node.parentNode as HTMLElement;
+ }
}
+ return false;
}
+ /**
+ * Programmatically triggers a link if the clicked mouse event contains one.
+ *
+ * @returns {boolean} True if link was successfully triggered.
+ */
+ protected triggerLinkMouse(): boolean {
+ let node = this.refocus;
+ while (node && node !== this.node) {
+ if (this.triggerLink(node)) {
+ return true;
+ }
+ node = node.parentNode as HTMLElement;
+ }
+ return false;
+ }
+
+ /**
+ * @returns {string} The semantic id of the node that is currently focused.
+ */
+ public semanticFocus(): string {
+ const focus = [];
+ let name = 'data-semantic-id';
+ let node = this.current || this.refocus || this.node;
+ const action = this.actionable(node);
+ if (action) {
+ name = action.hasAttribute('data-maction-id') ? 'data-maction-id' : 'id';
+ node = action;
+ focus.push(nav);
+ }
+ const attr = node.getAttribute(name);
+ if (attr) {
+ focus.unshift(`[${name}="${attr}"]`);
+ }
+ return focus.join(' ');
+ }
}
diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts
index 715723ef3..056b4b7a5 100644
--- a/ts/a11y/explorer/MouseExplorer.ts
+++ b/ts/a11y/explorer/MouseExplorer.ts
@@ -1,6 +1,6 @@
/*************************************************************
*
- * Copyright (c) 2009-2022 The MathJax Consortium
+ * Copyright (c) 2009-2025 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,57 +15,56 @@
* limitations under the License.
*/
-
/**
- * @fileoverview Explorers based on mouse events.
+ * @file Explorers based on mouse events.
*
* @author v.sorge@mathjax.org (Volker Sorge)
*/
-
-import {A11yDocument, DummyRegion, Region} from './Region.js';
-import {Explorer, AbstractExplorer} from './Explorer.js';
+import { A11yDocument, DummyRegion, Region } from './Region.js';
+import { Explorer, AbstractExplorer } from './Explorer.js';
+import { ExplorerPool } from './ExplorerPool.js';
import '../sre.js';
-
/**
* Interface for mouse explorers. Adds the necessary mouse events.
+ *
* @interface
- * @extends {Explorer}
+ * @augments {Explorer}
*/
export interface MouseExplorer extends Explorer {
-
/**
* Function to be executed on mouse over.
+ *
* @param {MouseEvent} event The mouse event.
*/
MouseOver(event: MouseEvent): void;
/**
* Function to be executed on mouse out.
+ *
* @param {MouseEvent} event The mouse event.
*/
MouseOut(event: MouseEvent): void;
-
}
-
/**
- * @constructor
- * @extends {AbstractExplorer}
+ * @class
+ * @augments {AbstractExplorer}
*
* @template T The type that is consumed by the Region of this explorer.
*/
-export abstract class AbstractMouseExplorer extends AbstractExplorer implements MouseExplorer {
-
+export abstract class AbstractMouseExplorer
+ extends AbstractExplorer
+ implements MouseExplorer
+{
/**
* @override
*/
- protected events: [string, (x: Event) => void][] =
- super.Events().concat([
- ['mouseover', this.MouseOver.bind(this)],
- ['mouseout', this.MouseOut.bind(this)]
- ]);
+ protected events: [string, (x: Event) => void][] = super.Events().concat([
+ ['mouseover', this.MouseOver.bind(this)],
+ ['mouseout', this.MouseOut.bind(this)],
+ ]);
/**
* @override
@@ -74,75 +73,63 @@ export abstract class AbstractMouseExplorer extends AbstractExplorer imple
this.Start();
}
-
/**
* @override
*/
public MouseOut(_event: MouseEvent) {
this.Stop();
}
-
}
-
/**
* Exploration via hovering.
- * @constructor
- * @extends {AbstractMouseExplorer}
+ *
+ * @class
+ * @augments {AbstractMouseExplorer}
+ *
+ * @template T
*/
export abstract class Hoverer extends AbstractMouseExplorer {
-
- /**
- * Remember the last position to avoid flickering.
- * @type {[number, number]}
- */
- protected coord: [number, number];
-
/**
- * @constructor
- * @extends {AbstractMouseExplorer}
+ * @class
+ * @augments {AbstractMouseExplorer}
*
* @param {A11yDocument} document The current document.
+ * @param {ExplorerPool} pool The explorer pool.
* @param {Region} region A region to display results.
* @param {HTMLElement} node The node on which the explorer works.
* @param {(node: HTMLElement) => boolean} nodeQuery Predicate on nodes that
* will fire the hoverer.
* @param {(node: HTMLElement) => T} nodeAccess Accessor to extract node value
* that is passed to the region.
- *
- * @template T
*/
- protected constructor(public document: A11yDocument,
- protected region: Region,
- protected node: HTMLElement,
- protected nodeQuery: (node: HTMLElement) => boolean,
- protected nodeAccess: (node: HTMLElement) => T) {
- super(document, region, node);
+ protected constructor(
+ public document: A11yDocument,
+ public pool: ExplorerPool,
+ public region: Region,
+ protected node: HTMLElement,
+ protected nodeQuery: (node: HTMLElement) => boolean,
+ protected nodeAccess: (node: HTMLElement) => T
+ ) {
+ super(document, pool, region, node);
}
-
/**
* @override
*/
public MouseOut(event: MouseEvent) {
- if (event.clientX === this.coord[0] &&
- event.clientY === this.coord[1]) {
- return;
- }
this.highlighter.unhighlight();
this.region.Hide();
super.MouseOut(event);
}
-
/**
* @override
*/
public MouseOver(event: MouseEvent) {
super.MouseOver(event);
- let target = event.target as HTMLElement;
- this.coord = [event.clientX, event.clientY];
- let [node, kind] = this.getNode(target);
+ const target = event.target as HTMLElement;
+ const [node, kind] = this.getNode(target);
if (!node) {
return;
}
@@ -152,7 +139,6 @@ export abstract class Hoverer extends AbstractMouseExplorer {
this.region.Show(node, this.highlighter);
}
-
/**
* Retrieves the closest node on which the node query fires. Thereby closest
* is defined as:
@@ -161,10 +147,10 @@ export abstract class Hoverer extends AbstractMouseExplorer {
* 3. Otherwise fails.
*
* @param {HTMLElement} node The node on which the mouse event fired.
- * @return {[HTMLElement, T]} Node and output pair if successful.
+ * @returns {[HTMLElement, T]} Node and output pair if successful.
*/
public getNode(node: HTMLElement): [HTMLElement, T] {
- let original = node;
+ const original = node;
while (node && node !== this.node) {
if (this.nodeQuery(node)) {
return [node, this.nodeAccess(node)];
@@ -176,49 +162,55 @@ export abstract class Hoverer extends AbstractMouseExplorer {
if (this.nodeQuery(node)) {
return [node, this.nodeAccess(node)];
}
- let child = node.childNodes[0] as HTMLElement;
- node = (child && child.tagName === 'defs') ? // This is for SVG.
- node.childNodes[1] as HTMLElement : child;
+ const child = node.childNodes[0] as HTMLElement;
+ node =
+ child && child.tagName === 'defs' // This is for SVG.
+ ? (node.childNodes[1] as HTMLElement)
+ : child;
}
return [null, null];
}
-
}
-
/**
* Hoverer that displays information on nodes (e.g., as tooltips).
- * @constructor
- * @extends {Hoverer}
+ *
+ * @class
+ * @augments {Hoverer}
*/
-export class ValueHoverer extends Hoverer { }
-
+export class ValueHoverer extends Hoverer {}
/**
* Hoverer that displays node content (e.g., for magnification).
- * @constructor
- * @extends {Hoverer}
+ *
+ * @class
+ * @augments {Hoverer}
*/
-export class ContentHoverer extends Hoverer { }
-
+export class ContentHoverer extends Hoverer {}
/**
* Highlights maction nodes on hovering.
- * @constructor
- * @extends {Hoverer}
+ *
+ * @class
+ * @augments {Hoverer}
*/
export class FlameHoverer extends Hoverer {
-
/**
* @override
*/
protected constructor(
public document: A11yDocument,
+ public pool: ExplorerPool,
_ignore: any,
- protected node: HTMLElement) {
- super(document, new DummyRegion(document), node,
- x => this.highlighter.isMactionNode(x),
- () => {});
+ protected node: HTMLElement
+ ) {
+ super(
+ document,
+ pool,
+ new DummyRegion(document),
+ node,
+ (x) => this.highlighter.isMactionNode(x),
+ () => {}
+ );
}
-
}
diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts
index e34fb1dba..109a42d2b 100644
--- a/ts/a11y/explorer/Region.ts
+++ b/ts/a11y/explorer/Region.ts
@@ -1,6 +1,6 @@
/*************************************************************
*
- * Copyright (c) 2009-2022 The MathJax Consortium
+ * Copyright (c) 2009-2025 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,22 +15,20 @@
* limitations under the License.
*/
-
/**
- * @fileoverview Regions for A11y purposes.
+ * @file Regions for A11y purposes.
*
* @author v.sorge@mathjax.org (Volker Sorge)
*/
-
-import {MathDocument} from '../../core/MathDocument.js';
-import {CssStyles} from '../../util/StyleList.js';
-import Sre from '../sre.js';
+import { MathDocument } from '../../core/MathDocument.js';
+import { StyleJsonSheet } from '../../util/StyleJson.js';
+import { Highlighter, getHighlighter } from './Highlighter.js';
+import { SsmlElement, buildSpeech } from '../speech/SpeechUtil.js';
export type A11yDocument = MathDocument;
export interface Region {
-
/**
* Adds a style sheet for the live region to the document.
*/
@@ -43,10 +41,11 @@ export interface Region {
/**
* Shows the live region in the document.
+ *
* @param {HTMLElement} node
- * @param {Sre.highlighter} highlighter
+ * @param {Highlighter} highlighter
*/
- Show(node: HTMLElement, highlighter: Sre.highlighter): void;
+ Show(node: HTMLElement, highlighter: Highlighter): void;
/**
* Takes the element out of the document flow.
@@ -60,61 +59,64 @@ export interface Region {
/**
* Updates the content of the region.
+ *
* @template T
*/
Update(content: T): void;
-
}
export abstract class AbstractRegion implements Region {
-
/**
* CSS Classname of the element.
- * @type {String}
+ *
+ * @type {string}
*/
protected static className: string;
/**
* True if the style has already been added to the document.
+ *
* @type {boolean}
*/
protected static styleAdded: boolean = false;
/**
* The CSS style that needs to be added for this type of region.
- * @type {CssStyles}
+ *
+ * @type {StyleJsonSheet}
*/
- protected static style: CssStyles;
+ protected static style: StyleJsonSheet;
/**
* The outer div node.
+ *
* @type {HTMLElement}
*/
- protected div: HTMLElement;
+ public div: HTMLElement;
/**
* The inner node.
+ *
* @type {HTMLElement}
*/
protected inner: HTMLElement;
/**
* The actual class name to refer to static elements of a class.
+ *
* @type {typeof AbstractRegion}
*/
protected CLASS: typeof AbstractRegion;
/**
- * @constructor
+ * @class
* @param {A11yDocument} document The document the live region is added to.
*/
constructor(public document: A11yDocument) {
this.CLASS = this.constructor as typeof AbstractRegion;
this.AddStyles();
- this.AddElement();
}
-
/**
* @override
*/
@@ -123,77 +125,76 @@ export abstract class AbstractRegion implements Region {
return;
}
// TODO: should that be added to document.documentStyleSheet()?
- let node = this.document.adaptor.node('style');
+ const node = this.document.adaptor.node('style');
node.innerHTML = this.CLASS.style.cssText;
- this.document.adaptor.head(this.document.adaptor.document).
- appendChild(node);
+ this.document.adaptor
+ .head(this.document.adaptor.document)
+ .appendChild(node);
this.CLASS.styleAdded = true;
}
-
/**
* @override
*/
public AddElement() {
- let element = this.document.adaptor.node('div');
+ if (this.div) return;
+ const element = this.document.adaptor.node('div');
element.classList.add(this.CLASS.className);
- element.style.backgroundColor = 'white';
this.div = element;
this.inner = this.document.adaptor.node('div');
this.div.appendChild(this.inner);
- this.document.adaptor.
- body(this.document.adaptor.document).
- appendChild(this.div);
-
+ this.document.adaptor
+ .body(this.document.adaptor.document)
+ .appendChild(this.div);
}
-
/**
* @override
*/
- public Show(node: HTMLElement, highlighter: Sre.highlighter) {
+ public Show(node: HTMLElement, highlighter: Highlighter) {
+ this.AddElement();
this.position(node);
this.highlight(highlighter);
this.div.classList.add(this.CLASS.className + '_Show');
}
-
/**
* Computes the position where to place the element wrt. to the given node.
+ *
* @param {HTMLElement} node The reference node.
*/
protected abstract position(node: HTMLElement): void;
-
/**
* Highlights the region.
- * @param {Sre.highlighter} highlighter The Sre highlighter.
+ *
+ * @param {Highlighter} highlighter The Sre highlighter.
*/
- protected abstract highlight(highlighter: Sre.highlighter): void;
-
+ protected abstract highlight(highlighter: Highlighter): void;
/**
* @override
*/
public Hide() {
- this.div.classList.remove(this.CLASS.className + '_Show');
+ if (!this.div) return;
+ this.div.parentNode.removeChild(this.div);
+ this.div = null;
+ this.inner = null;
}
-
/**
* @override
*/
public abstract Clear(): void;
-
/**
* @override
*/
public abstract Update(content: T): void;
-
/**
* Auxiliary position method that stacks shown regions of the same type.
+ *
* @param {HTMLElement} node The reference node.
*/
protected stackRegions(node: HTMLElement) {
@@ -201,25 +202,30 @@ export abstract class AbstractRegion implements Region {
const rect = node.getBoundingClientRect();
let baseBottom = 0;
let baseLeft = Number.POSITIVE_INFINITY;
- let regions = this.document.adaptor.document.getElementsByClassName(
- this.CLASS.className + '_Show');
+ const regions = this.document.adaptor.document.getElementsByClassName(
+ this.CLASS.className + '_Show'
+ );
// Get all the shown regions (one is this element!) and append at bottom.
- for (let i = 0, region; region = regions[i]; i++) {
+ for (let i = 0, region; (region = regions[i]); i++) {
if (region !== this.div) {
- baseBottom = Math.max(region.getBoundingClientRect().bottom, baseBottom);
+ baseBottom = Math.max(
+ region.getBoundingClientRect().bottom,
+ baseBottom
+ );
baseLeft = Math.min(region.getBoundingClientRect().left, baseLeft);
}
}
- const bot = (baseBottom ? baseBottom : rect.bottom + 10) + window.pageYOffset;
- const left = (baseLeft < Number.POSITIVE_INFINITY ? baseLeft : rect.left) + window.pageXOffset;
+
+ const bot = (baseBottom ? baseBottom : rect.bottom + 10) + window.scrollY;
+ const left =
+ (baseLeft < Number.POSITIVE_INFINITY ? baseLeft : rect.left) +
+ window.scrollX;
this.div.style.top = bot + 'px';
this.div.style.left = left + 'px';
}
-
}
export class DummyRegion extends AbstractRegion {
-
/**
* @override
*/
@@ -258,28 +264,31 @@ export class DummyRegion extends AbstractRegion {
/**
* @override
*/
- public highlight(_highlighter: Sre.highlighter) {}
+ public highlight(_highlighter: Highlighter) {}
}
-
export class StringRegion extends AbstractRegion {
-
/**
* @override
*/
public Clear(): void {
+ if (!this.div) return;
this.Update('');
this.inner.style.top = '';
this.inner.style.backgroundColor = '';
}
-
/**
* @override
*/
public Update(speech: string) {
- this.inner.textContent = '';
- this.inner.textContent = speech;
+ if (speech) {
+ this.AddElement();
+ }
+ if (this.inner) {
+ this.inner.textContent = '';
+ this.inner.textContent = speech || '\u00a0';
+ }
}
/**
@@ -289,21 +298,17 @@ export class StringRegion extends AbstractRegion {
this.stackRegions(node);
}
-
/**
* @override
*/
- protected highlight(highlighter: Sre.highlighter) {
- const color = highlighter.colorString();
- this.inner.style.backgroundColor = color.background;
- this.inner.style.color = color.foreground;
+ protected highlight(highlighter: Highlighter) {
+ if (!this.div) return;
+ this.inner.style.backgroundColor = highlighter.background;
+ this.inner.style.color = highlighter.foreground;
}
-
}
-
export class ToolTip extends StringRegion {
-
/**
* @override
*/
@@ -312,25 +317,28 @@ export class ToolTip extends StringRegion {
/**
* @override
*/
- protected static style: CssStyles =
- new CssStyles({
- ['.' + ToolTip.className]: {
- position: 'absolute', display: 'inline-block',
- height: '1px', width: '1px'
- },
- ['.' + ToolTip.className + '_Show']: {
- width: 'auto', height: 'auto', opacity: 1, 'text-align': 'center',
- 'border-radius': '6px', padding: '0px 0px',
- 'border-bottom': '1px dotted black', position: 'absolute',
- 'z-index': 202
- }
- });
-
+ protected static style: StyleJsonSheet = new StyleJsonSheet({
+ ['.' + ToolTip.className]: {
+ width: 'auto',
+ height: 'auto',
+ opacity: 1,
+ 'text-align': 'center',
+ 'border-radius': '4px',
+ padding: 0,
+ 'border-bottom': '1px dotted black',
+ position: 'absolute',
+ display: 'inline-block',
+ 'background-color': 'white',
+ 'z-index': 202,
+ },
+ ['.' + ToolTip.className + ' > div']: {
+ 'border-radius': 'inherit',
+ padding: '0 2px',
+ },
+ });
}
-
export class LiveRegion extends StringRegion {
-
/**
* @override
*/
@@ -339,69 +347,244 @@ export class LiveRegion extends StringRegion {
/**
* @override
*/
- protected static style: CssStyles =
- new CssStyles({
- ['.' + LiveRegion.className]: {
- position: 'absolute', top: '0', height: '1px', width: '1px',
- padding: '1px', overflow: 'hidden'
- },
- ['.' + LiveRegion.className + '_Show']: {
- top: '0', position: 'absolute', width: 'auto', height: 'auto',
- padding: '0px 0px', opacity: 1, 'z-index': '202',
- left: 0, right: 0, 'margin': '0 auto',
- 'background-color': 'rgba(0, 0, 255, 0.2)', 'box-shadow': '0px 10px 20px #888',
- border: '2px solid #CCCCCC'
- }
- });
-
+ protected static style: StyleJsonSheet = new StyleJsonSheet({
+ ['.' + LiveRegion.className]: {
+ position: 'absolute',
+ top: 0,
+ display: 'none',
+ width: 'auto',
+ height: 'auto',
+ padding: 0,
+ opacity: 1,
+ 'z-index': '202',
+ left: 0,
+ right: 0,
+ margin: '0 auto',
+ 'background-color': 'white',
+ 'box-shadow': '0px 5px 20px #888',
+ border: '2px solid #CCCCCC',
+ },
+ ['.' + LiveRegion.className + '_Show']: {
+ display: 'block',
+ },
+ });
+}
+/**
+ * Region class that enables auto voicing of content via SSML markup.
+ */
+export class SpeechRegion extends LiveRegion {
/**
- * @constructor
- * @param {A11yDocument} document The document the live region is added to.
+ * Flag to activate auto voicing.
*/
- constructor(public document: A11yDocument) {
- super(document);
- this.div.setAttribute('aria-live', 'assertive');
- }
+ public active: boolean = false;
-}
+ /**
+ * The math expression that is currently explored. Other regions do not need
+ * this node as the explorer administers both node and region, while only
+ * pushing output into the region. But in the case autovoicing the speech
+ * regions needs to mark elements in the node directly.
+ */
+ public node: Element = null;
+ /**
+ * Flag to indicate if a node is marked as being spoken.
+ */
+ private clear: boolean = false;
-// Region that overlays the current element.
-export class HoverRegion extends AbstractRegion {
+ /**
+ * The highlighter to use.
+ */
+ public highlighter: Highlighter = getHighlighter(
+ { color: 'red' },
+ { color: 'black' },
+ this.document.outputJax.name
+ );
/**
* @override
*/
- protected static className = 'MJX_HoverRegion';
+ public Show(node: HTMLElement, highlighter: Highlighter) {
+ super.Update('\u00a0'); // Ensures region shown and cannot be overwritten.
+ this.node = node;
+ super.Show(node, highlighter);
+ }
+
+ /**
+ * Have we already requested voices from the browser?
+ */
+ private voiceRequest: boolean = false;
+
+ /**
+ * Has the auto voicing been cancelled?
+ */
+ private voiceCancelled: boolean = false;
/**
* @override
*/
- protected static style: CssStyles =
- new CssStyles({
- ['.' + HoverRegion.className]: {
- position: 'absolute', height: '1px', width: '1px',
- padding: '1px', overflow: 'hidden'
- },
- ['.' + HoverRegion.className + '_Show']: {
- position: 'absolute', width: 'max-content', height: 'auto',
- padding: '0px 0px', opacity: 1, 'z-index': '202', 'margin': '0 auto',
- 'background-color': 'rgba(0, 0, 255, 0.2)',
- 'box-shadow': '0px 10px 20px #888', border: '2px solid #CCCCCC'
- }
+ public Update(speech: string) {
+ // TODO (Volker): Make sure we use speech and ssml!
+ if (this.voiceRequest) {
+ this.makeVoice(speech);
+ return;
+ }
+ speechSynthesis.onvoiceschanged = (() => (this.voiceRequest = true)).bind(
+ this
+ );
+ const promise = new Promise((resolve) => {
+ setTimeout(() => {
+ if (this.voiceRequest) {
+ resolve(true);
+ } else {
+ // This case is to make FF and Safari work.
+ setTimeout(() => {
+ this.voiceRequest = true;
+ resolve(true);
+ }, 100);
+ }
+ }, 100);
});
+ promise.then(() => this.makeVoice(speech));
+ }
+ private makeVoice(speech: string) {
+ this.active =
+ this.document.options.a11y.voicing &&
+ !!speechSynthesis.getVoices().length;
+ speechSynthesis.cancel();
+ this.clear = true;
+ const [text, ssml] = buildSpeech(
+ speech,
+ this.document.options.sre.locale,
+ this.document.options.sre.rate
+ );
+ super.Update(text);
+ if (this.active && text) {
+ this.makeUtterances(ssml, this.document.options.sre.locale);
+ }
+ }
/**
- * @constructor
- * @param {A11yDocument} document The document the live region is added to.
+ * Generates the utterance chain.
+ *
+ * @param {SsmlElement[]} ssml The list of ssml annotations.
+ * @param {string} locale The locale to use.
+ */
+ protected makeUtterances(ssml: SsmlElement[], locale: string) {
+ this.voiceCancelled = false;
+ let utterance = null;
+ for (const utter of ssml) {
+ if (utter.mark) {
+ if (!utterance) {
+ // First utterance, call with init = true.
+ this.highlightNode(utter.mark, true);
+ continue;
+ }
+ utterance.addEventListener('end', (_event: Event) => {
+ if (!this.voiceCancelled) {
+ this.highlightNode(utter.mark);
+ }
+ });
+ continue;
+ }
+ if (utter.pause) {
+ const time = parseInt(utter.pause.match(/^[0-9]+/)[0]);
+ if (isNaN(time) || !utterance) {
+ continue;
+ }
+ // TODO: Ensure pausing does not advance the highlighting.
+ utterance.addEventListener('end', (_event: Event) => {
+ speechSynthesis.pause();
+ setTimeout(() => {
+ speechSynthesis.resume();
+ }, time);
+ });
+ continue;
+ }
+ utterance = new SpeechSynthesisUtterance(utter.text);
+ if (utter.rate) {
+ utterance.rate = utter.rate;
+ }
+ if (utter.pitch) {
+ utterance.pitch = utter.pitch;
+ }
+ utterance.lang = locale;
+ speechSynthesis.speak(utterance);
+ }
+ if (utterance) {
+ utterance.addEventListener('end', (_event: Event) => {
+ this.highlighter.unhighlight();
+ });
+ }
+ }
+
+ /**
+ * @override
*/
- constructor(public document: A11yDocument) {
- super(document);
- this.inner.style.lineHeight = '0';
+ public Hide() {
+ this.cancelVoice();
+ super.Hide();
+ }
+
+ /**
+ * Cancel the auto-voicing
+ */
+ public cancelVoice() {
+ this.voiceCancelled = true;
+ speechSynthesis.cancel();
+ this.highlighter.unhighlight();
}
+ /**
+ * Highlighting the node that is being marked in the SSML.
+ *
+ * @param {string} id The id of the node to highlight.
+ * @param {boolean} init Flag to indicate the very first utterance where there
+ * is no need for unhighlighting.
+ */
+ private highlightNode(id: string, init: boolean = false) {
+ this.highlighter.unhighlight();
+ const nodes = Array.from(
+ this.node.querySelectorAll(`[data-semantic-id="${id}"]`)
+ );
+ if (!this.clear || init) {
+ this.highlighter.highlight(nodes as HTMLElement[]);
+ }
+ this.clear = false;
+ }
+}
+
+// Region that overlays the current element.
+export class HoverRegion extends AbstractRegion {
+ /**
+ * @override
+ */
+ protected static className = 'MJX_HoverRegion';
+
+ /**
+ * @override
+ */
+ protected static style: StyleJsonSheet = new StyleJsonSheet({
+ ['.' + HoverRegion.className]: {
+ display: 'block',
+ position: 'absolute',
+ width: 'max-content',
+ height: 'auto',
+ padding: 0,
+ opacity: 1,
+ 'z-index': '202',
+ margin: '0 auto',
+ 'background-color': 'white',
+ 'line-height': 0,
+ 'box-shadow': '0px 10px 20px #888',
+ border: '2px solid #CCCCCC',
+ },
+ ['.' + HoverRegion.className + ' > div']: {
+ overflow: 'hidden',
+ },
+ });
+
/**
* Sets the position of the region with respect to align parameter. There are
* three options: top, bottom and center. Center is the default.
@@ -411,25 +594,26 @@ export class HoverRegion extends AbstractRegion {
protected position(node: HTMLElement) {
const nodeRect = node.getBoundingClientRect();
const divRect = this.div.getBoundingClientRect();
- const xCenter = nodeRect.left + (nodeRect.width / 2);
- let left = xCenter - (divRect.width / 2);
- left = (left < 0) ? 0 : left;
- left = left + window.pageXOffset;
+ const xCenter = nodeRect.left + nodeRect.width / 2;
+ let left = xCenter - divRect.width / 2;
+ left = left < 0 ? 0 : left;
+ left = left + window.scrollX;
let top;
switch (this.document.options.a11y.align) {
- case 'top':
- top = nodeRect.top - divRect.height - 10 ;
- break;
- case 'bottom':
- top = nodeRect.bottom + 10;
- break;
- case 'center':
- default:
- const yCenter = nodeRect.top + (nodeRect.height / 2);
- top = yCenter - (divRect.height / 2);
+ case 'top':
+ top = nodeRect.top - divRect.height - 10;
+ break;
+ case 'bottom':
+ top = nodeRect.bottom + 10;
+ break;
+ case 'center':
+ default: {
+ const yCenter = nodeRect.top + nodeRect.height / 2;
+ top = yCenter - divRect.height / 2;
+ }
}
- top = top + window.pageYOffset;
- top = (top < 0) ? 0 : top;
+ top = top + window.scrollY;
+ top = top < 0 ? 0 : top;
this.div.style.top = top + 'px';
this.div.style.left = left + 'px';
}
@@ -437,21 +621,24 @@ export class HoverRegion extends AbstractRegion {
/**
* @override
*/
- protected highlight(highlighter: Sre.highlighter) {
+ protected highlight(highlighter: Highlighter) {
+ if (!this.div) return;
// TODO Do this with styles to avoid the interaction of SVG/CHTML.
- if (this.inner.firstChild &&
- !(this.inner.firstChild as HTMLElement).hasAttribute('sre-highlight')) {
+ if (
+ this.inner.firstChild &&
+ !(this.inner.firstChild as HTMLElement).hasAttribute('sre-highlight')
+ ) {
return;
}
- const color = highlighter.colorString();
- this.inner.style.backgroundColor = color.background;
- this.inner.style.color = color.foreground;
+ this.inner.style.backgroundColor = highlighter.background;
+ this.inner.style.color = highlighter.foreground;
}
/**
* @override
*/
- public Show(node: HTMLElement, highlighter: Sre.highlighter) {
+ public Show(node: HTMLElement, highlighter: Highlighter) {
+ this.AddElement();
this.div.style.fontSize = this.document.options.a11y.magnify;
this.Update(node);
super.Show(node, highlighter);
@@ -461,6 +648,7 @@ export class HoverRegion extends AbstractRegion {
* @override
*/
public Clear() {
+ if (!this.div) return;
this.inner.textContent = '';
this.inner.style.top = '';
this.inner.style.backgroundColor = '';
@@ -470,18 +658,26 @@ export class HoverRegion extends AbstractRegion {
* @override
*/
public Update(node: HTMLElement) {
+ if (!this.div) return;
this.Clear();
- let mjx = this.cloneNode(node);
+ const mjx = this.cloneNode(node);
+ const selected = mjx.querySelector('[data-mjx-clone]') as HTMLElement;
+ this.inner.style.backgroundColor = node.style.backgroundColor;
+ selected.style.backgroundColor = '';
+ selected.classList.remove('mjx-selected');
this.inner.appendChild(mjx);
+ this.position(node);
}
/**
* Clones the node to put into the hover region.
+ *
* @param {HTMLElement} node The original node.
- * @return {HTMLElement} The cloned node.
+ * @returns {HTMLElement} The cloned node.
*/
private cloneNode(node: HTMLElement): HTMLElement {
let mjx = node.cloneNode(true) as HTMLElement;
+ mjx.setAttribute('data-mjx-clone', 'true');
if (mjx.nodeName !== 'MJX-CONTAINER') {
// remove element spacing (could be done in CSS)
if (mjx.nodeName !== 'g') {
@@ -498,22 +694,28 @@ export class HoverRegion extends AbstractRegion {
// SVG specific
//
if (mjx.nodeName === 'svg') {
- (mjx.firstChild as HTMLElement).setAttribute('transform', 'matrix(1 0 0 -1 0 0)');
+ (mjx.firstChild as HTMLElement).setAttribute(
+ 'transform',
+ 'matrix(1 0 0 -1 0 0)'
+ );
const W = parseFloat(mjx.getAttribute('viewBox').split(/ /)[2]);
const w = parseFloat(mjx.getAttribute('width'));
- const {x, y, width, height} = (node as any).getBBox();
- mjx.setAttribute('viewBox', [x, -(y + height), width, height].join(' '));
+ const { x, y, width, height } = (node as any).getBBox();
+ mjx.setAttribute(
+ 'viewBox',
+ [x, -(y + height), width, height].join(' ')
+ );
mjx.removeAttribute('style');
- mjx.setAttribute('width', (w / W * width) + 'ex');
- mjx.setAttribute('height', (w / W * height) + 'ex');
+ mjx.setAttribute('width', (w / W) * width + 'ex');
+ mjx.setAttribute('height', (w / W) * height + 'ex');
container.setAttribute('sre-highlight', 'false');
}
}
- mjx = container.cloneNode(false).appendChild(mjx).parentNode as HTMLElement;
+ mjx = container.cloneNode(false).appendChild(mjx)
+ .parentNode as HTMLElement;
// remove displayed math margins (could be done in CSS)
mjx.style.margin = '0';
}
return mjx;
}
-
}
diff --git a/ts/a11y/explorer/TreeExplorer.ts b/ts/a11y/explorer/TreeExplorer.ts
index aa6838156..94915de8e 100644
--- a/ts/a11y/explorer/TreeExplorer.ts
+++ b/ts/a11y/explorer/TreeExplorer.ts
@@ -1,6 +1,6 @@
/*************************************************************
*
- * Copyright (c) 2009-2022 The MathJax Consortium
+ * Copyright (c) 2009-2025 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,34 +15,29 @@
* limitations under the License.
*/
-
/**
- * @fileoverview Tree Explorers allow to switch on effects on the entire
+ * @file Tree Explorers allow to switch on effects on the entire
* expression tree.
*
* @author v.sorge@mathjax.org (Volker Sorge)
*/
-
-import {A11yDocument, Region} from './Region.js';
-import {Explorer, AbstractExplorer} from './Explorer.js';
-import Sre from '../sre.js';
-
-export interface TreeExplorer extends Explorer {
-
-}
-
+import { A11yDocument, Region } from './Region.js';
+import { AbstractExplorer } from './Explorer.js';
+import { ExplorerPool } from './ExplorerPool.js';
export class AbstractTreeExplorer extends AbstractExplorer {
-
/**
* @override
*/
- protected constructor(public document: A11yDocument,
- protected region: Region,
- protected node: HTMLElement,
- protected mml: HTMLElement) {
- super(document, null, node);
+ protected constructor(
+ public document: A11yDocument,
+ public pool: ExplorerPool,
+ public region: Region,
+ protected node: HTMLElement,
+ protected mml: HTMLElement
+ ) {
+ super(document, pool, null, node);
}
/**
@@ -50,7 +45,6 @@ export class AbstractTreeExplorer extends AbstractExplorer {
*/
public readonly stoppable = false;
-
/**
* @override
*/
@@ -66,12 +60,9 @@ export class AbstractTreeExplorer extends AbstractExplorer {
this.Stop();
super.Detach();
}
-
}
-
export class FlameColorer extends AbstractTreeExplorer {
-
/**
* @override
*/
@@ -90,11 +81,16 @@ export class FlameColorer extends AbstractTreeExplorer {
}
this.active = false;
}
-
}
-
export class TreeColorer extends AbstractTreeExplorer {
+ /**
+ * Contrast value.
+ */
+ public contrast: ContrastPicker = new ContrastPicker();
+
+ private leaves: HTMLElement[] = [];
+ private modality: string = 'data-semantic-foreground';
/**
* @override
@@ -102,13 +98,12 @@ export class TreeColorer extends AbstractTreeExplorer {
public Start() {
if (this.active) return;
this.active = true;
- let generator = Sre.getSpeechGenerator('Color');
if (!this.node.hasAttribute('hasforegroundcolor')) {
- generator.generateSpeech(this.node, this.mml);
+ this.colorLeaves();
this.node.setAttribute('hasforegroundcolor', 'true');
}
// TODO: Make this cleaner in Sre.
- (this.highlighter as any).colorizeAll(this.node);
+ this.leaves.forEach((leaf) => this.colorize(leaf));
}
/**
@@ -116,9 +111,118 @@ export class TreeColorer extends AbstractTreeExplorer {
*/
public Stop() {
if (this.active) {
- (this.highlighter as any).uncolorizeAll(this.node);
+ this.leaves.forEach((leaf) => this.uncolorize(leaf));
}
this.active = false;
}
+ /**
+ * Colors the leave nodes of the expression.
+ */
+ private colorLeaves() {
+ this.leaves = Array.from(
+ this.node.querySelectorAll(
+ '[data-semantic-id]:not([data-semantic-children])'
+ )
+ );
+ for (const leaf of this.leaves) {
+ leaf.setAttribute(this.modality, this.contrast.generate());
+ this.contrast.increment();
+ }
+ }
+
+ /**
+ * Tree colors a single node.
+ *
+ * @param {HTMLElement} node The node.
+ */
+ public colorize(node: HTMLElement) {
+ if (node.hasAttribute(this.modality)) {
+ node.setAttribute(this.modality + '-old', node.style.color);
+ node.style.color = node.getAttribute(this.modality);
+ }
+ }
+
+ /**
+ * Removes tree coloring from a single node.
+ *
+ * @param {HTMLElement} node The node.
+ */
+ public uncolorize(node: HTMLElement) {
+ const fore = this.modality + '-old';
+ if (node.hasAttribute(fore)) {
+ node.style.color = node.getAttribute(fore);
+ }
+ }
+}
+
+export class ContrastPicker {
+ /**
+ * Hue value.
+ */
+ public hue = 10;
+
+ /**
+ * Saturation value.
+ */
+ public sat = 100;
+
+ /**
+ * Light value.
+ */
+ public light = 50;
+
+ /**
+ * Increment step. Prime closest to 50.
+ */
+ public incr = 53;
+
+ /**
+ * Generates the current color as rgb color in hex code.
+ *
+ * @returns {string} The rgb color attribute.
+ */
+ public generate(): string {
+ return ContrastPicker.hsl2rgb(this.hue, this.sat, this.light);
+ }
+
+ /**
+ * Increments the hue value of the current color.
+ */
+ public increment() {
+ this.hue = (this.hue + this.incr) % 360;
+ }
+
+ /**
+ * Transforms a HSL triple into an rgb value triple.
+ *
+ * @param {number} h The hue.
+ * @param {number} s The saturation.
+ * @param {number} l The luminosity.
+ * @returns {string} The string with rgb value triple with values in [0, 255].
+ */
+ public static hsl2rgb(h: number, s: number, l: number): string {
+ s = s > 1 ? s / 100 : s;
+ l = l > 1 ? l / 100 : l;
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = l - c / 2;
+ let r = 0,
+ g = 0,
+ b = 0;
+ if (0 <= h && h < 60) {
+ [r, g, b] = [c, x, 0];
+ } else if (60 <= h && h < 120) {
+ [r, g, b] = [x, c, 0];
+ } else if (120 <= h && h < 180) {
+ [r, g, b] = [0, c, x];
+ } else if (180 <= h && h < 240) {
+ [r, g, b] = [0, x, c];
+ } else if (240 <= h && h < 300) {
+ [r, g, b] = [x, 0, c];
+ } else if (300 <= h && h < 360) {
+ [r, g, b] = [c, 0, x];
+ }
+ return `rgb(${(r + m) * 255}, ${(g + m) * 255}, ${(b + m) * 255})`;
+ }
}
diff --git a/ts/a11y/semantic-enrich.ts b/ts/a11y/semantic-enrich.ts
index 48bb6086a..61c776f3f 100644
--- a/ts/a11y/semantic-enrich.ts
+++ b/ts/a11y/semantic-enrich.ts
@@ -1,6 +1,6 @@
/*************************************************************
*
- * Copyright (c) 2018-2022 The MathJax Consortium
+ * Copyright (c) 2018-2025 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,45 +16,88 @@
*/
/**
- * @fileoverview Mixin that adds semantic enrichment to internal MathML
+ * @file Mixin that adds semantic enrichment to internal MathML
*
* @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';
-import {MmlNode} from '../core/MmlTree/MmlNode.js';
-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 { Handler } from '../core/Handler.js';
+import {
+ MathDocument,
+ AbstractMathDocument,
+ MathDocumentConstructor,
+} from '../core/MathDocument.js';
+import {
+ MathItem,
+ AbstractMathItem,
+ STATE,
+ newState,
+} from '../core/MathItem.js';
+import { MmlNode } from '../core/MmlTree/MmlNode.js';
+import { HtmlNode } from '../core/MmlTree/MmlNodes/HtmlNode.js';
+import { MathML } from '../input/mathml.js';
+import { SerializedMmlVisitor } from '../core/MmlTree/SerializedMmlVisitor.js';
+import { OptionList, expandable } from '../util/Options.js';
+import * as Sre from './sre.js';
/*==========================================================================*/
-/**
- * The current speech setting for Sre
- */
-let currentSpeech = 'none';
-
/**
* Generic constructor for Mixins
*/
-export type Constructor = new(...args: any[]) => T;
+export type Constructor = new (...args: any[]) => T;
/*==========================================================================*/
/**
* Add STATE value for being enriched (after COMPILED and before TYPESET)
*/
-newState('ENRICHED', 30);
+newState('ENRICHED', STATE.COMPILED + 10);
-/**
- * Add STATE value for adding speech (after TYPESET)
- */
-newState('ATTACHSPEECH', 155);
+/*==========================================================================*/
+export class enrichVisitor extends SerializedMmlVisitor {
+ protected mactionId: number;
+
+ public visitTree(node: MmlNode, math?: MathItem) {
+ this.mactionId = 0;
+ const mml = super.visitTree(node);
+ if (this.mactionId) {
+ math.inputData.hasMaction = true;
+ }
+ return mml;
+ }
+
+ public visitHtmlNode(node: HtmlNode, _space: string): string {
+ return node.getSerializedXML();
+ }
+
+ public visitMactionNode(node: MmlNode, space: string) {
+ const [nl, endspace] =
+ node.childNodes.length === 0 ? ['', ''] : ['\n', space];
+ const children = this.childNodeMml(node, space + ' ', nl);
+ let attributes = this.getAttributes(node);
+ if (node.attributes.get('actiontype') === 'toggle') {
+ const id = ++this.mactionId;
+ node.setProperty('mactionId', id);
+ //
+ // Add maction id and make sure selection is the next attribute
+ //
+ attributes =
+ ` data-maction-id="${id}" selection="${node.attributes.get('selection')}"` +
+ attributes
+ .replace(/ selection="\d+"/, '')
+ .replace(/ data-maction-id="\d+"/, '');
+ }
+ return (
+ `${space}` +
+ (children.match(/\S/) ? nl + children + endspace : '') +
+ ''
+ );
+ }
+}
+
+/*==========================================================================*/
/**
* The functions added to MathItem for enrichment
@@ -64,6 +107,10 @@ newState('ATTACHSPEECH', 155);
* @template D The Document class
*/
export interface EnrichedMathItem extends MathItem {
+ /**
+ * The serialization visitor
+ */
+ toMathML: (node: MmlNode, math: MathItem) => string;
/**
* @param {MathDocument} document The document where enrichment is occurring
@@ -72,9 +119,15 @@ export interface EnrichedMathItem extends MathItem {
enrich(document: MathDocument, force?: boolean): void;
/**
- * @param {MathDocument} document The document where enrichment is occurring
+ * @param {MathDocument} document The MathDocument for the MathItem
+ */
+ unEnrich(document: MathDocument): void;
+
+ /**
+ * @param {string} mml The MathML string to enrich
+ * @returns {string} The enriched MathML
*/
- attachSpeech(document: MathDocument): void;
+ toEnriched(mml: string): string;
}
/**
@@ -83,24 +136,32 @@ export interface EnrichedMathItem extends MathItem {
* @param {B} BaseMathItem The MathItem class to be extended
* @param {MathML} MmlJax The MathML input jax used to convert the enriched MathML
* @param {Function} toMathML The function to serialize the internal MathML
- * @return {EnrichedMathItem} The enriched MathItem class
+ * @returns {EnrichedMathItem} The enriched MathItem class
*
* @template N The HTMLElement node class
* @template T The Text node class
* @template D The Document class
* @template B The MathItem class to extend
*/
-export function EnrichedMathItemMixin>>(
+export function EnrichedMathItemMixin<
+ N,
+ T,
+ D,
+ B extends Constructor>,
+>(
BaseMathItem: B,
MmlJax: MathML,
- toMathML: (node: MmlNode) => string
+ toMathML: (node: MmlNode, math: MathItem) => string
): Constructor> & B {
-
return class extends BaseMathItem {
+ /**
+ * The MathML serializer
+ */
+ public toMathML = toMathML;
/**
* @param {any} node The node to be serialized
- * @return {string} The serialized version of node
+ * @returns {string} The serialized version of node
*/
protected serializeMml(node: any): string {
if ('outerHTML' in node) {
@@ -109,7 +170,11 @@ export function EnrichedMathItemMixin, force: boolean = false) {
if (this.state() >= STATE.ENRICHED) return;
if (!this.isEscaped && (document.options.enableEnrichment || force)) {
- if (document.options.sre.speech !== currentSpeech) {
- currentSpeech = document.options.sre.speech;
- mathjax.retryAfter(
- Sre.setupEngine(document.options.sre).then(
- () => Sre.sreReady()));
- }
const math = new document.options.MathItem('', MmlJax);
try {
- const mml = this.inputData.originalMml = toMathML(this.root);
- math.math = this.serializeMml(Sre.toEnriched(mml));
+ let mml;
+ if (!this.inputData.originalMml) {
+ mml = this.inputData.originalMml = this.toMathML(this.root, this);
+ } else {
+ mml = this.adjustSelections();
+ }
+ const enriched = Sre.toEnriched(mml);
+ this.inputData.enrichedMml = math.math = this.serializeMml(enriched);
+ //
+ // Replace treeitem with a data attribute marking speech nodes
+ // and remove unused aria attributes. This will be removed when
+ // SRE is updated to do this itself.
+ //
+ math.math = math.math
+ .replace(/ role="treeitem"/g, ' data-speech-node="true"')
+ .replace(/ aria-(?:posinset|owns|setsize)=".*?"/g, '');
math.display = this.display;
math.compile(document);
this.root = math.root;
- this.inputData.enrichedMml = math.math;
} catch (err) {
document.options.enrichError(document, this, err);
}
@@ -148,48 +220,48 @@ export function EnrichedMathItemMixin) {
- if (this.state() >= STATE.ATTACHSPEECH) return;
- const attributes = this.root.attributes;
- const speech = (attributes.get('aria-label') ||
- this.getSpeech(this.root)) as string;
- if (speech) {
- const adaptor = document.adaptor;
- const node = this.typesetRoot;
- adaptor.setAttribute(node, 'aria-label', speech);
- for (const child of adaptor.childNodes(node) as N[]) {
- adaptor.setAttribute(child, 'aria-hidden', 'true');
- }
- }
- this.state(STATE.ATTACHSPEECH);
+ public unEnrich(document: MathDocument) {
+ const mml = this.inputData.originalMml;
+ if (!mml) return;
+ const math = new document.options.MathItem('', MmlJax);
+ math.math = mml;
+ math.display = this.display;
+ math.compile(document);
+ this.root = math.root;
}
/**
- * Retrieves the actual speech element that should be used as aria label.
- * @param {MmlNode} node The root node to search from.
- * @return {string} The speech content.
+ * Correct the selection values for the maction items from the original MathML
+ *
+ * @returns {string} The updated MathML element.
*/
- private getSpeech(node: MmlNode): string {
- const attributes = node.attributes;
- if (!attributes) return '';
- const speech = attributes.getExplicit('data-semantic-speech') as string;
- if (!attributes.getExplicit('data-semantic-parent') && speech) {
- return speech;
- }
- for (let child of node.childNodes) {
- let value = this.getSpeech(child as MmlNode);
- if (value != null) {
- return value;
+ protected adjustSelections(): string {
+ const mml = this.inputData.originalMml;
+ if (!this.inputData.hasMaction) return mml;
+ const maction = [] as MmlNode[];
+ this.root.walkTree((node: MmlNode) => {
+ if (node.isKind('maction')) {
+ maction[node.attributes.get('data-maction-id') as number] = node;
}
- }
- return '';
+ });
+ return mml.replace(
+ /(data-maction-id="(\d+)" selection=)"\d+"/g,
+ (_match: string, prefix: string, id: number) =>
+ `${prefix}"${maction[id].attributes.get('selection')}"`
+ );
}
-
};
-
}
/*==========================================================================*/
@@ -201,28 +273,25 @@ export function EnrichedMathItemMixin extends AbstractMathDocument {
-
+export interface EnrichedMathDocument
+ extends AbstractMathDocument