From ee6e50a75c8e32b54ca43f43d296854c47862acd Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 28 Jul 2026 14:38:00 -0400 Subject: [PATCH 01/13] Move parsing of ds-structure to StructureUtil class --- ts/a11y/explorer/KeyExplorer.ts | 99 +++--------------------------- ts/a11y/speech/StructureUtil.ts | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 90 deletions(-) create mode 100644 ts/a11y/speech/StructureUtil.ts diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index eb34e58fe..ef61c9436 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -29,6 +29,7 @@ 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 { StructureUtil, StructureMap } from '../speech/StructureUtil.js'; import { context } from '../../util/context.js'; import { InfoDialog } from '../../ui/dialog/InfoDialog.js'; import { localize } from './__locales__/Component.js'; @@ -366,7 +367,7 @@ export class SpeechExplorer /** * Semantic id to subtree map. */ - private subtrees: Map> = null; + private subtrees: StructureMap = null; /** * @override @@ -1141,7 +1142,7 @@ export class SpeechExplorer Array.from(node.querySelectorAll(`[data-semantic-id]`)) as HTMLElement[] ).forEach((x) => children.add(this.nodeId(x))); } - const rest = setdifference(sub, children); + const rest = StructureUtil.setdifference(sub, children); return [...rest] .map((child) => this.getNode(child)) .filter((node) => node !== null); @@ -1774,8 +1775,7 @@ export class SpeechExplorer */ public async Start() { if (!this.subtrees) { - this.subtrees = new Map(); - this.getSubtrees(); + this.subtrees = this.getSubtrees(); } // // If we aren't attached or already active, return @@ -2023,94 +2023,13 @@ export class SpeechExplorer /** * Populates the subtrees map from the data-semantic-structure attribute. + * + * @returns {StructureMap} The structure of the expression. */ - private getSubtrees() { + protected getSubtrees(): StructureMap { const node = this.node.querySelector('[data-semantic-structure]'); - if (!node) return; + if (!node) return new Map(); const sexp = node.getAttribute('data-semantic-structure'); - const tokens = tokenize(sexp); - const tree = parse(tokens); - buildMap(tree, this.subtrees); - } -} - -/**********************************************************************/ -/* - * Some Aux functions for parsing the semantic structure sexpression - */ -type SexpTree = string | SexpTree[]; - -/** - * Helper to tokenize input - * - * @param {string} str The semantic structure. - * @returns {string[]} The tokenized list. - */ -function tokenize(str: string): string[] { - return str.replace(/\(/g, ' ( ').replace(/\)/g, ' ) ').trim().split(/\s+/); -} - -/** - * Recursive parser to convert tokens into a tree - * - * @param {string} tokens The tokens from the semantic structure. - * @returns {SexpTree} Array list for the semantic structure sexpression. - */ -function parse(tokens: string[]): SexpTree { - const stack: SexpTree[][] = [[]]; - for (const token of tokens) { - if (token === '(') { - const newNode: SexpTree = []; - stack[stack.length - 1].push(newNode); - stack.push(newNode); - } else if (token === ')') { - stack.pop(); - } else { - stack[stack.length - 1].push(token); - } - } - return stack[0][0]; -} - -/** - * Flattens the tree and builds the map. - * - * @param {SexpTree} tree The sexpression tree. - * @param {Map>} map The map to populate. - * @returns {Set} The descendant map. - */ -function buildMap(tree: SexpTree, map: Map>): Set { - if (typeof tree === 'string') { - if (!map.has(tree)) map.set(tree, new Set()); - return new Set(); - } - const [root, ...children] = tree; - const rootId = root as string; - const descendants: Set = new Set(); - for (const child of children) { - const childRoot = typeof child === 'string' ? child : child[0]; - const childDescendants = buildMap(child, map); - descendants.add(childRoot as string); - childDescendants.forEach((d: string) => descendants.add(d)); - } - map.set(rootId, descendants); - return descendants; -} - -// Can be replaced with ES2024 implementation of Set.prototyp.difference -/** - * Set difference between two sets A and B: A\B. - * - * @param {Set} a Initial set. - * @param {Set} b Set to remove from A. - * @returns {Set} The difference A\B. - */ -function setdifference(a: Set, b: Set): Set { - if (!a) { - return new Set(); - } - if (!b) { - return a; + return StructureUtil.getStructure(sexp); } - return new Set([...a].filter((x) => !b.has(x))); } diff --git a/ts/a11y/speech/StructureUtil.ts b/ts/a11y/speech/StructureUtil.ts new file mode 100644 index 000000000..47a683b62 --- /dev/null +++ b/ts/a11y/speech/StructureUtil.ts @@ -0,0 +1,105 @@ +/**********************************************************************/ +/* + * Some Aux functions for parsing the semantic structure sexpression + */ + +export type StructureMap = Map>; +type SexpTree = string | SexpTree[]; + +export class StructureUtil { + /** + * Create the subtree mapping for an expression's structure sexp. + * + * @param {string} sexp The structure sexp to process + * @returns {StructureMap} The map from element ids to related ids + */ + public static getStructure(sexp: string): StructureMap { + const map: StructureMap = new Map(); + this.buildMap(this.parse(this.tokenize(sexp)), map); + for (const x of map.keys()) { + if (map.get(x).size === 0) { + map.delete(x); + } + } + return map; + } + + /** + * Helper to tokenize input + * + * @param {string} str The semantic structure. + * @returns {string[]} The tokenized list. + */ + public static tokenize(str: string): string[] { + return str.replace(/\(/g, ' ( ').replace(/\)/g, ' ) ').trim().split(/\s+/); + } + + /** + * Recursive parser to convert tokens into a tree + * + * @param {string} tokens The tokens from the semantic structure. + * @returns {SexpTree} Array list for the semantic structure sexpression. + */ + public static parse(tokens: string[]): SexpTree { + const stack: SexpTree[][] = [[]]; + for (const token of tokens) { + if (token === '(') { + const newNode: SexpTree = []; + stack[stack.length - 1].push(newNode); + stack.push(newNode); + } else if (token === ')') { + stack.pop(); + } else { + stack[stack.length - 1].push(token); + } + } + return stack[0][0]; + } + + /** + * Flattens the tree and builds the map. + * + * @param {SexpTree} tree The sexpression tree. + * @param {Map>} map The map to populate. + * @returns {Set} The descendant map. + */ + public static buildMap(tree: SexpTree, map: StructureMap): Set { + if (typeof tree === 'string') { + if (!map.has(tree)) map.set(tree, new Set()); + return new Set(); + } + const [root, ...children] = tree; + const rootId = root as string; + const descendants: Set = new Set(); + for (const child of children) { + const childRoot = typeof child === 'string' ? child : child[0]; + const childDescendants = this.buildMap(child, map); + descendants.add(childRoot as string); + childDescendants.forEach((d: string) => descendants.add(d)); + } + map.set(rootId, descendants); + return descendants; + } + + // Can be replaced with ES2024 implementation of Set.prototype.difference + /** + * Set difference between two sets A and B: A\B. + * + * @param {Set} a Initial set. + * @param {Set} b Set to remove from A. + * @returns {Set} The difference A\B. + */ + public static setdifference(a: Set, b: Set): Set { + if (!a) { + return new Set(); + } + if (!b) { + return a; + } + if ((a as any).difference) { + return (a as any).difference(b); + } + return new Set([...a].filter((x) => !b.has(x))); + } + +} From a8c93815a11bd776b4aeb2c5b98198235063dfb0 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 31 Jul 2026 15:02:50 -0400 Subject: [PATCH 02/13] Update how extra nodes are calculated, and use that in KeyExplorer to get the split nodes --- ts/a11y/explorer/KeyExplorer.ts | 61 ++------------- ts/a11y/semantic-enrich.ts | 27 +++++++ ts/a11y/speech/StructureUtil.ts | 112 ++++++++++++++-------------- ts/core/Tree/Node.ts | 14 ++-- ts/output/chtml/Wrappers/maction.ts | 15 ++-- ts/output/svg/Wrappers/maction.ts | 15 ++-- 6 files changed, 117 insertions(+), 127 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index ef61c9436..d8277900f 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -29,7 +29,6 @@ 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 { StructureUtil, StructureMap } from '../speech/StructureUtil.js'; import { context } from '../../util/context.js'; import { InfoDialog } from '../../ui/dialog/InfoDialog.js'; import { localize } from './__locales__/Component.js'; @@ -364,11 +363,6 @@ export class SpeechExplorer ['dblclick', this.DblClick.bind(this)], ]); - /** - * Semantic id to subtree map. - */ - private subtrees: StructureMap = null; - /** * @override */ @@ -1100,8 +1094,6 @@ export class SpeechExplorer this.node.removeAttribute('aria-busy'); } - private cacheParts: Map = new Map(); - /** * Get all nodes with the same semantic id (multiple nodes if there are line breaks). * @@ -1113,39 +1105,10 @@ export class SpeechExplorer if (!id) { return [node]; } - // Here we need to cache the subtrees. - if (this.cacheParts.has(id)) { - return this.cacheParts.get(id); - } - const parts = Array.from( - this.node.querySelectorAll(`[data-semantic-id="${id}"]`) - ) as HTMLElement[]; - const subtree = this.subtree(id, parts); - this.cacheParts.set(id, [...parts, ...subtree]); - return this.cacheParts.get(id); - } - - /** - * Retrieve the elements in the semantic subtree that are not in the DOM subtree. - * - * @param {string} id The semantic id of the root node. - * @param {HTMLElement[]} nodes The list of nodes corresponding to that id - * (could be multiple for linebroken ones). - * @returns {HTMLElement[]} The list of nodes external to the DOM trees rooted - * by any of the input nodes. - */ - private subtree(id: string, nodes: HTMLElement[]): HTMLElement[] { - const sub = this.subtrees.get(id); - const children: Set = new Set(); - for (const node of nodes) { - ( - Array.from(node.querySelectorAll(`[data-semantic-id]`)) as HTMLElement[] - ).forEach((x) => children.add(this.nodeId(x))); - } - const rest = StructureUtil.setdifference(sub, children); - return [...rest] - .map((child) => this.getNode(child)) - .filter((node) => node !== null); + const nodes = (this.item.semanticNodes.get(id) ?? [id]).map( + (nid) => Array.from(this.node.querySelectorAll(`[data-semantic-id="${nid}"]`)) + ).flat() as HTMLElement[]; + return nodes; } /** @@ -1774,9 +1737,7 @@ export class SpeechExplorer * @override */ public async Start() { - if (!this.subtrees) { - this.subtrees = this.getSubtrees(); - } + this.item.parseSemanticNodes(); // // If we aren't attached or already active, return // @@ -2020,16 +1981,4 @@ export class SpeechExplorer } return focus.join(' '); } - - /** - * Populates the subtrees map from the data-semantic-structure attribute. - * - * @returns {StructureMap} The structure of the expression. - */ - protected getSubtrees(): StructureMap { - const node = this.node.querySelector('[data-semantic-structure]'); - if (!node) return new Map(); - const sexp = node.getAttribute('data-semantic-structure'); - return StructureUtil.getStructure(sexp); - } } diff --git a/ts/a11y/semantic-enrich.ts b/ts/a11y/semantic-enrich.ts index 587b96829..11088d16d 100644 --- a/ts/a11y/semantic-enrich.ts +++ b/ts/a11y/semantic-enrich.ts @@ -39,6 +39,7 @@ 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'; +import { StructureUtil, SemanticMap } from './speech/StructureUtil.js'; import { Locale } from '../util/Locale.js'; import { COMPONENT } from './semantic-enrich/__locales__/Component.js'; @@ -109,6 +110,16 @@ export class enrichVisitor extends SerializedMmlVisitor { * @template D The Document class */ export interface EnrichedMathItem extends MathItem { + /** + * Maps semantic ids to extra nodes outside the DOM subtree. + */ + semanticNodes: SemanticMap; + + /** + * Get any extra nodes outside the DOM tree from the semantic structure + */ + parseSemanticNodes(): void; + /** * The serialization visitor */ @@ -161,6 +172,20 @@ export function EnrichedMathItemMixin< */ public toMathML = toMathML; + /** + * Semantic id to extra nodes outside the DOM subtree + */ + public semanticNodes: SemanticMap; + + /** + * @override + */ + public parseSemanticNodes() { + if (!this.semanticNodes) { + this.semanticNodes = StructureUtil.semanticNodes(this.root); + } + } + /** * @param {any} node The node to be serialized * @returns {string} The serialized version of node @@ -194,6 +219,7 @@ export function EnrichedMathItemMixin< public enrich(document: MathDocument, force: boolean = false) { if (this.state() >= STATE.ENRICHED) return; if (!this.isEscaped && (document.options.enableEnrichment || force)) { + this.semanticNodes = null; const math = new document.options.MathItem('', MmlJax); try { let mml; @@ -242,6 +268,7 @@ export function EnrichedMathItemMixin< math.display = this.display; math.compile(document); this.root = math.root; + this.semanticNodes = null; } /** diff --git a/ts/a11y/speech/StructureUtil.ts b/ts/a11y/speech/StructureUtil.ts index 47a683b62..d640f7229 100644 --- a/ts/a11y/speech/StructureUtil.ts +++ b/ts/a11y/speech/StructureUtil.ts @@ -1,36 +1,22 @@ +import { MmlNode } from '../../core/MmlTree/MmlNode.js'; + /**********************************************************************/ /* * Some Aux functions for parsing the semantic structure sexpression */ -export type StructureMap = Map>; -type SexpTree = string | SexpTree[]; +export type SexpTree = string | SexpTree[]; +export type ParentMap = Map; +export type SemanticMap = Map; export class StructureUtil { - /** - * Create the subtree mapping for an expression's structure sexp. - * - * @param {string} sexp The structure sexp to process - * @returns {StructureMap} The map from element ids to related ids - */ - public static getStructure(sexp: string): StructureMap { - const map: StructureMap = new Map(); - this.buildMap(this.parse(this.tokenize(sexp)), map); - for (const x of map.keys()) { - if (map.get(x).size === 0) { - map.delete(x); - } - } - return map; - } - /** * Helper to tokenize input * * @param {string} str The semantic structure. * @returns {string[]} The tokenized list. */ - public static tokenize(str: string): string[] { + protected static tokenize(str: string): string[] { return str.replace(/\(/g, ' ( ').replace(/\)/g, ' ) ').trim().split(/\s+/); } @@ -40,7 +26,7 @@ export class StructureUtil { * @param {string} tokens The tokens from the semantic structure. * @returns {SexpTree} Array list for the semantic structure sexpression. */ - public static parse(tokens: string[]): SexpTree { + protected static parse(tokens: string[]): SexpTree { const stack: SexpTree[][] = [[]]; for (const token of tokens) { if (token === '(') { @@ -57,49 +43,67 @@ export class StructureUtil { } /** - * Flattens the tree and builds the map. + * Recursively map semantic ids to the nearest parent ids * - * @param {SexpTree} tree The sexpression tree. - * @param {Map>} map The map to populate. - * @returns {Set} The descendant map. + * @param {MmlNode} node The node to process + * @param {string} id The id of the parent node + * @param {ParentMap} map The map being built + * @returns {ParentMap} The map of semantic ids to their nearset parent ids */ - public static buildMap(tree: SexpTree, map: StructureMap): Set { - if (typeof tree === 'string') { - if (!map.has(tree)) map.set(tree, new Set()); - return new Set(); + protected static mapParents(node: MmlNode, id: string = '', map: ParentMap = new Map()): ParentMap { + const nid = node.attributes.get('data-semantic-id') as string; + if (nid) { + map.set(nid, id); } - const [root, ...children] = tree; - const rootId = root as string; - const descendants: Set = new Set(); - for (const child of children) { - const childRoot = typeof child === 'string' ? child : child[0]; - const childDescendants = this.buildMap(child, map); - descendants.add(childRoot as string); - childDescendants.forEach((d: string) => descendants.add(d)); + if (node.isToken) return map; + for (const child of node.childNodes) { + this.mapParents(child, nid ?? id, map); } - map.set(rootId, descendants); - return descendants; + return map; } - // Can be replaced with ES2024 implementation of Set.prototype.difference /** - * Set difference between two sets A and B: A\B. + * Map the semantic ids to themselves and any nodes outside their MathML tree * - * @param {Set} a Initial set. - * @param {Set} b Set to remove from A. - * @returns {Set} The difference A\B. + * @param {MmlNode} root The root node to process. + * @returns {SemanticMap} The map of node ids to arrays of node ids for those that have + * nodes outside their MathML subtree. */ - public static setdifference(a: Set, b: Set): Set { - if (!a) { - return new Set(); - } - if (!b) { - return a; + public static semanticNodes(root: MmlNode): SemanticMap { + let sexp = ''; + root.walkTree((node) => { + sexp = node.attributes?.get('data-semantic-structure') as string; + return !!sexp; + }); + const tree = this.parse(this.tokenize(sexp)); + const parents = this.mapParents(root); + const map = new Map() as SemanticMap; + this.mapExtras(tree, parents, map); + return map; + } + + /** + * Recursive helper function for semanticNodes(). + * + * @param {SexpTree} tree The semantic structure array. + * @param {ParentMap} parents The map from semantic ids to their parent ids. + * @param {SemanticMap} map The map being built. + * @returns {string[]} The semantic nodes outside the MathML subtree. + */ + protected static mapExtras(tree: SexpTree, parents: ParentMap, map: SemanticMap): string[] { + if (!Array.isArray(tree)) return [tree]; + const id = tree[0] as string; + const extra: string[] = []; + for (const child of tree.slice(1)) { + for (const nid of this.mapExtras(child, parents, map)) { + if (parents.get(nid) !== id) { + extra.push(nid); + } + } } - if ((a as any).difference) { - return (a as any).difference(b); + if (extra.length) { + map.set(id, [id, ...extra]); } - return new Set([...a].filter((x) => !b.has(x))); + return extra; } - } diff --git a/ts/core/Tree/Node.ts b/ts/core/Tree/Node.ts index d9269b76d..8d72cfb54 100644 --- a/ts/core/Tree/Node.ts +++ b/ts/core/Tree/Node.ts @@ -124,8 +124,9 @@ export interface Node, C extends NodeClass> { /** * @param {Function} func A function to apply to each node in the tree rooted at this node * @param {any} data Data to pass to the function (as state information) + * @returns {any} The (possibly modified) data structure */ - walkTree(func: (node: N, data?: any) => void, data?: any): void; + walkTree(func: (node: N, data?: any) => boolean | void, data?: any): any; } /*********************************************************/ @@ -332,11 +333,14 @@ export abstract class AbstractNode< /** * @override */ - public walkTree(func: (node: N, data?: any) => void, data?: any) { - func(this as any as N, data); + public walkTree(func: (node: N, data?: any) => boolean | void, data?: any, state: {continue: boolean} = {continue: true}): any { + if (func(this as any as N, data)) { + state.continue = false; + return data; + }; for (const child of this.childNodes) { - if (child) { - child.walkTree(func, data); + if (child && state.continue) { + (child as unknown as AbstractNode).walkTree(func, data, state); } } return data; diff --git a/ts/output/chtml/Wrappers/maction.ts b/ts/output/chtml/Wrappers/maction.ts index df8c341ec..68a167ea9 100644 --- a/ts/output/chtml/Wrappers/maction.ts +++ b/ts/output/chtml/Wrappers/maction.ts @@ -43,6 +43,7 @@ import { EventHandler, TooltipData } from '../../common/Wrappers/maction.js'; import { TextNode } from '../../../core/MmlTree/MmlNode.js'; import { StyleJson } from '../../../util/StyleJson.js'; import { STATE } from '../../../core/MathItem.js'; +import { mathjax } from '../../../mathjax.js'; /*****************************************************************/ /** @@ -248,12 +249,14 @@ export const ChtmlMaction = (function (): ChtmlMactionClass { math.start.n = math.end.n = 0; } mml.nextToggleSelection(); - math.rerender( - document, - mml.attributes.get('data-maction-id') - ? STATE.ENRICHED - : STATE.RERENDER - ); + mathjax.handleRetriesFor(() => { + math.rerender( + document, + mml.attributes.get('data-maction-id') + ? STATE.ENRICHED + : STATE.RERENDER + ); + }); event.stopPropagation(); }); }, diff --git a/ts/output/svg/Wrappers/maction.ts b/ts/output/svg/Wrappers/maction.ts index 57666aab7..696362c34 100644 --- a/ts/output/svg/Wrappers/maction.ts +++ b/ts/output/svg/Wrappers/maction.ts @@ -46,6 +46,7 @@ import { } from '../../../core/MmlTree/MmlNode.js'; import { StyleJson } from '../../../util/StyleJson.js'; import { STATE } from '../../../core/MathItem.js'; +import { mathjax } from '../../../mathjax.js'; /*****************************************************************/ /** @@ -246,12 +247,14 @@ export const SvgMaction = (function (): SvgMactionClass { math.start.n = math.end.n = 0; } mml.nextToggleSelection(); - math.rerender( - document, - mml.attributes.get('data-maction-id') - ? STATE.ENRICHED - : STATE.RERENDER - ); + mathjax.handleRetriesFor(() => { + math.rerender( + document, + mml.attributes.get('data-maction-id') + ? STATE.ENRICHED + : STATE.RERENDER + ); + }); event.stopPropagation(); }); }, From c08af89aa6739872cdeb65e91545e7ccd61b54a3 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Sun, 2 Aug 2026 08:15:50 -0400 Subject: [PATCH 03/13] Fix magnification to include multiple split nodes and extra nodes --- ts/a11y/explorer.ts | 40 ++++++++++---- ts/a11y/explorer/ExplorerPool.ts | 79 +++++---------------------- ts/a11y/explorer/KeyExplorer.ts | 23 +------- ts/a11y/explorer/MouseExplorer.ts | 72 +++++++++++++++++++++--- ts/a11y/explorer/Region.ts | 91 +++++++++++++++++++------------ ts/a11y/explorer/TreeExplorer.ts | 1 - 6 files changed, 169 insertions(+), 137 deletions(-) diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index ebfa30b68..59fa717b4 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -22,13 +22,11 @@ */ import { Handler } from '../core/Handler.js'; -import { MmlNode } from '../core/MmlTree/MmlNode.js'; import { MathML } from '../input/mathml.js'; import { STATE, newState } from '../core/MathItem.js'; import { SpeechMathItem, SpeechMathDocument, SpeechHandler } from './speech.js'; import { MathDocumentConstructor } from '../core/MathDocument.js'; import { OptionList, expandable } from '../util/Options.js'; -import { SerializedMmlVisitor } from '../core/MmlTree/SerializedMmlVisitor.js'; import { hasWindow } from '../util/context.js'; import { StyleJson } from '../util/StyleJson.js'; import { context } from '../util/context.js'; @@ -112,20 +110,27 @@ export interface ExplorerMathItem extends HTMLMATHITEM { * @param {HTMLElement} focus The temporary focus element, if any */ clearTemporaryFocus(focus: HTMLElement): void; + + /** + * 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 + */ + getSplitNodes(node: HTMLElement): HTMLElement[]; } /** * The mixin for adding the Explorer to MathItems * * @param {B} BaseMathItem The MathItem class to be extended - * @param {Function} toMathML The function to serialize the internal MathML * @returns {ExplorerMathItem} The Explorer MathItem class * * @template B The MathItem class to extend */ export function ExplorerMathItemMixin>( BaseMathItem: B, - toMathML: (node: MmlNode) => string ): Constructor & B { return class BaseClass extends BaseMathItem { /** @@ -214,11 +219,10 @@ export function ExplorerMathItemMixin>( if (this.state() >= STATE.EXPLORER) return; if (!this.isEscaped && (document.options.enableExplorer || force)) { const node = this.typesetRoot; - const mml = toMathML(this.root); if (!this.explorers) { this.explorers = new ExplorerPool(); } - this.explorers.init(document, node, mml, this); + this.explorers.init(document, node, this); } this.state(STATE.EXPLORER); } @@ -282,6 +286,25 @@ export function ExplorerMathItemMixin>( promise.then(() => setTimeout(() => focus.remove(), 100)); } } + + /** + * 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 + */ + public getSplitNodes(node: HTMLElement): HTMLElement[] { + const id = node.getAttribute('data-semantic-id'); + if (!id) { + return [node]; + } + const nodes = (this.semanticNodes.get(id) ?? [id]).map( + (nid: string) => Array.from( + this.typesetRoot.querySelectorAll(`[data-semantic-id="${nid}"]`) + ) + ).flat() as HTMLElement[]; + return nodes; + } }; } @@ -499,15 +522,12 @@ export function ExplorerMathDocumentMixin< if (!ProcessBits.has('explorer')) { ProcessBits.allocate('explorer'); } - const visitor = new SerializedMmlVisitor(this.mmlFactory); - const toMathML = (node: MmlNode) => visitor.visitTree(node); const options = this.options; if (!options.a11y.speechRules) { options.a11y.speechRules = `${options.sre.domain}-${options.sre.style}`; } const mathItem = (options.MathItem = ExplorerMathItemMixin( - options.MathItem, - toMathML + options.MathItem )); mathItem.roleDescription = options.roleDescription; this.explorerRegions = new RegionPool(this); diff --git a/ts/a11y/explorer/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts index 56fb1a3a7..e90e99762 100644 --- a/ts/a11y/explorer/ExplorerPool.ts +++ b/ts/a11y/explorer/ExplorerPool.ts @@ -84,19 +84,14 @@ type ExplorerInit = ( doc: ExplorerMathDocument, pool: ExplorerPool, node: HTMLElement, - ...rest: any[] + item: ExplorerMathItem, ) => 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[] - ) => { + speech: (doc, pool, node, item) => { const explorer = SpeechExplorer.create( doc, pool, @@ -104,86 +99,46 @@ const allExplorers: { [options: string]: ExplorerInit } = { node, doc.explorerRegions.brailleRegion, doc.explorerRegions.magnifier, - rest[0], - rest[1] + item ) as SpeechExplorer; explorer.sound = true; return explorer; }, - mouseMagnifier: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => + mouseMagnifier: (doc, pool, node, item) => me.ContentHoverer.create( doc, pool, doc.explorerRegions.magnifier, node, - (x: HTMLElement) => x.hasAttribute('data-semantic-type'), - (x: HTMLElement) => x + item ), - 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[] - ) => + hover: (doc, pool, node) => me.FlameHoverer.create(doc, pool, null, node), + infoType: (doc, pool, node) => me.ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip1, node, - (x: HTMLElement) => x.hasAttribute('data-semantic-type'), - (x: HTMLElement) => x.getAttribute('data-semantic-type') + 'data-semantic-type' ), - infoRole: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => + infoRole: (doc, pool, node) => me.ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip2, node, - (x: HTMLElement) => x.hasAttribute('data-semantic-role'), - (x: HTMLElement) => x.getAttribute('data-semantic-role') + 'data-semantic-role' ), - infoPrefix: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => + infoPrefix: (doc, pool, node) => 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') + '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), + flame: (doc, pool, node) => FlameColorer.create(doc, pool, null, node), + treeColoring: (doc, pool, node) => TreeColorer.create(doc, pool, null, node), }; /** @@ -220,7 +175,7 @@ export class ExplorerPool { /** * The corresponding Mathml node as a string. */ - protected mml: string; +// protected mml: string; /** * The primary highlighter shared by all explorers. @@ -254,17 +209,14 @@ export class ExplorerPool { /** * @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)) { @@ -272,7 +224,6 @@ export class ExplorerPool { this.document, this, this.node, - this.mml, item ); } diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index d8277900f..900a48ad1 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1076,7 +1076,7 @@ export class SpeechExplorer this.current = node; this.currentMark = -1; if (this.current) { - const parts = [...this.getSplitNodes(this.current)]; + const parts = [...this.item.getSplitNodes(this.current)]; this.highlighter.encloseNodes(parts, this.node); for (const part of parts) { if (!part.getAttribute('data-sre-enclosed')) { @@ -1094,23 +1094,6 @@ export class SpeechExplorer this.node.removeAttribute('aria-busy'); } - /** - * 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 getSplitNodes(node: HTMLElement): HTMLElement[] { - const id = this.nodeId(node); - if (!id) { - return [node]; - } - const nodes = (this.item.semanticNodes.get(id) ?? [id]).map( - (nid) => Array.from(this.node.querySelectorAll(`[data-semantic-id="${nid}"]`)) - ).flat() as HTMLElement[]; - return nodes; - } - /** * Remove the top-level speech node and create * a temporary one for the given node. @@ -1700,7 +1683,6 @@ export class SpeechExplorer * @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} @@ -1712,7 +1694,6 @@ export class SpeechExplorer protected node: HTMLElement, public brailleRegion: LiveRegion, public magnifyRegion: HoverRegion, - _mml: MmlNode, public item: ExplorerMathItem ) { super(document, pool, null, node); @@ -1782,6 +1763,7 @@ export class SpeechExplorer this.brailleRegion.Show(this.node); } if (a11y.keyMagnifier) { + this.magnifyRegion.splitNodes = this.item.getSplitNodes(this.current); this.magnifyRegion.Show(this.current); } this.Update(); @@ -1820,6 +1802,7 @@ export class SpeechExplorer this.brailleRegion ); } + this.magnifyRegion.splitNodes = this.item.getSplitNodes(this.current); this.magnifyRegion.Update(this.current); } diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index 2babc8a96..45070a786 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -21,9 +21,10 @@ * @author v.sorge@mathjax.org (Volker Sorge) */ -import { A11yDocument, DummyRegion, Region } from './Region.js'; +import { A11yDocument, DummyRegion, Region, HoverRegion, ToolTip } from './Region.js'; import { Explorer, AbstractExplorer } from './Explorer.js'; import { ExplorerPool } from './ExplorerPool.js'; +import type { ExplorerMathItem } from '../explorer.js'; import '../sre.js'; /** @@ -102,6 +103,7 @@ export abstract class Hoverer extends AbstractMouseExplorer { * will fire the hoverer. * @param {(node: HTMLElement) => T} nodeAccess Accessor to extract node value * that is passed to the region. + * @param {ExplorerMathItem} item The MathItem for this explorer */ protected constructor( public document: A11yDocument, @@ -109,7 +111,8 @@ export abstract class Hoverer extends AbstractMouseExplorer { public region: Region, protected node: HTMLElement, protected nodeQuery: (node: HTMLElement) => boolean, - protected nodeAccess: (node: HTMLElement) => T + protected nodeAccess: (node: HTMLElement) => T, + protected item: ExplorerMathItem = null ) { super(document, pool, region, node); } @@ -135,6 +138,14 @@ export abstract class Hoverer extends AbstractMouseExplorer { } this.highlighter.unhighlight(); this.highlighter.highlight([node]); + this.display(node, kind); + } + + /** + * @param {HTMLElement} node The target node to update + * @param {T} kind The target kind to update + */ + protected display(node: HTMLElement, kind: T) { this.region.Update(kind); this.region.Show(node); } @@ -178,7 +189,27 @@ export abstract class Hoverer extends AbstractMouseExplorer { * @class * @augments {Hoverer} */ -export class ValueHoverer extends Hoverer {} +export class ValueHoverer extends Hoverer { + /** + * @override + */ + protected constructor( + document: A11yDocument, + pool: ExplorerPool, + region: ToolTip, + node: HTMLElement, + attr: string, + ) { + super( + document, + pool, + region, + node, + (x) => x.hasAttribute?.(attr), + (x) => x.getAttribute?.(attr) + ); + } +} /** * Hoverer that displays node content (e.g., for magnification). @@ -186,7 +217,34 @@ export class ValueHoverer extends Hoverer {} * @class * @augments {Hoverer} */ -export class ContentHoverer extends Hoverer {} +export class ContentHoverer extends Hoverer { + /** + * @override + */ + protected constructor( + document: A11yDocument, + pool: ExplorerPool, + public region: HoverRegion, + node: HTMLElement, + item: ExplorerMathItem, + ) { + super( + document, + pool, + region, + node, + (x) => x.hasAttribute?.('data-semantic-id'), + (x) => x, + item + ); + } + + display(node: HTMLElement) { + this.item.parseSemanticNodes(); + this.region.splitNodes = this.item.getSplitNodes(node); + this.region.Show(node); + } +} /** * Highlights maction nodes on hovering. @@ -199,10 +257,10 @@ export class FlameHoverer extends Hoverer { * @override */ protected constructor( - public document: A11yDocument, - public pool: ExplorerPool, + document: A11yDocument, + pool: ExplorerPool, _ignore: any, - protected node: HTMLElement + node: HTMLElement ) { super( document, diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index 4a4f757ee..b80ee80d0 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -692,6 +692,9 @@ export class HoverRegion extends AbstractRegion { color: 'var(--mjx-fg1-color)', 'background-color': 'var(--mjx-bg1-color)', }, + [`.${HoverRegion.className} > div > mjx-container`]: { + display: 'flex', + }, '@media (prefers-color-scheme: dark)': { ['.' + HoverRegion.className]: { 'background-color': '#222025', @@ -702,6 +705,9 @@ export class HoverRegion extends AbstractRegion { 'mjx-container[data-mjx-clone-container]': { padding: '2px ! important', }, + 'mjx-container[data-mjx-clone-container][display] > mjx-math': { + 'text-align': 'center', + }, 'mjx-math > mjx-mlabeledtr': { display: 'inline-block', 'margin-right': '.5em ! important', @@ -718,6 +724,10 @@ export class HoverRegion extends AbstractRegion { * @param {HTMLElement} node The node that is displayed. */ protected position(node: HTMLElement) { + const prev = node.previousSibling as HTMLElement; + if (prev?.getAttribute('data-sre-highlighter-added')) { + node = prev; + } const nodeRect = node.getBoundingClientRect(); const divRect = this.div.getBoundingClientRect(); const xCenter = nodeRect.left + nodeRect.width / 2; @@ -764,6 +774,8 @@ export class HoverRegion extends AbstractRegion { this.inner.style.backgroundColor = ''; } + public splitNodes: any; + /** * @override */ @@ -773,8 +785,10 @@ export class HoverRegion extends AbstractRegion { 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'); + if (selected) { + selected.style.backgroundColor = ''; + selected.classList.remove('mjx-selected'); + } this.inner.appendChild(mjx); this.position(node); } @@ -798,10 +812,8 @@ export class HoverRegion extends AbstractRegion { if (math.nodeName === 'MJX-BBOX') { math = math.nextSibling; } - mjx = math.cloneNode(false).appendChild(mjx).parentElement; - const enclosed = Array.from( - container.querySelectorAll('[data-sre-enclosed]') - ); + mjx = math.cloneNode(false); + const enclosed = this.splitNodes; math.nodeName === 'svg' ? this.svgClone(node, enclosed, mjx, container) : this.chtmlClone(node, enclosed, mjx); @@ -820,16 +832,22 @@ export class HoverRegion extends AbstractRegion { * @param {HTMLElement} mjx The container for the clones */ protected chtmlClone( - node: HTMLElement, + node: Element, enclosed: Element[], mjx: HTMLElement ) { + const included = new Set(); for (const child of enclosed) { - if (child !== node) { - const id = child.getAttribute('data-semantic-id'); - if (!id || !mjx.querySelector(`[data-semantic-id="${id}"]`)) { - mjx.appendChild(child.cloneNode(true)); - } + const id = child.getAttribute('data-semantic-id'); + if (included.has(id)) { + mjx.appendChild(document.createElement('br')); + } + included.add(id); + const clone = mjx.appendChild(child.cloneNode(true)) as HTMLElement; + clone.classList.remove('mjx-selected'); + if (child === node) { + clone.setAttribute('data-mjx-clone', 'true'); + clone.removeAttribute('space'); } } } @@ -846,34 +864,37 @@ export class HoverRegion extends AbstractRegion { mjx: HTMLElement, container: Element ) { - let { x, y, width, height } = (node as SVGGraphicsElement).getBBox(); - if (enclosed.length) { - mjx.firstChild.remove(); - const g = container.querySelector('g').cloneNode(false); - for (const child of enclosed) { - const clone = g.appendChild(child.cloneNode(true)) as HTMLElement; - if (child === node) { - clone.setAttribute('data-mjx-clone', 'true'); - } - const [cx, cy] = this.xy(child); - clone.setAttribute('transform', `translate(${cx}, ${cy})`); + let [x, y] = [0, 0]; + let top, bot, left, right; + const g = container.querySelector('g').cloneNode(false); + for (const child of enclosed) { + const rect = child.previousSibling as SVGRectElement; + if (rect?.getAttribute('data-sre-highlighter-added')) { + const bbox = rect.getBBox(); + const [X, Y] = this.xy(rect); + x = X; y = Y + bbox.y; + if (left === undefined || x < left) left = x; + if (right === undefined || x + bbox.width > right) right = x + bbox.width; + top ??= bbox.height + bbox.y + Y; + bot = y; + } + const clone = g.appendChild(child.cloneNode(true)) as HTMLElement; + clone.classList.remove('mjx-selected'); + if (child === node) { + clone.setAttribute('data-mjx-clone', 'true'); } - mjx.appendChild(g); - const rect = node.previousSibling as SVGRectElement; - const bbox = rect.getBBox(); - width = bbox.width; - height = bbox.height; - const [X, Y] = this.xy(rect); - x = X; - y = Y + bbox.y; + const [cx, cy] = this.xy(child); + clone.setAttribute('transform', `translate(${cx}, ${cy})`); } + const height = top - bot; + const width = right - left; + mjx.appendChild(g); // // Handle top-level expression with a tag // - const g = container.querySelector('g'); if ( container.getAttribute('width') === 'full' && - g.firstChild.lastChild === node + container.querySelector('g').firstChild.lastChild === node ) { mjx.innerHTML = ''; mjx.appendChild(container.cloneNode(true).firstChild); @@ -891,7 +912,7 @@ export class HoverRegion extends AbstractRegion { ).split(/ /)[2] ); const w = parseFloat(mjx.style.minWidth || mjx.getAttribute('width')); - mjx.setAttribute('viewBox', [x, -(y + height), width, height].join(' ')); + mjx.setAttribute('viewBox', [left, -top, width, height].join(' ')); mjx.removeAttribute('style'); mjx.setAttribute('width', (w / W) * width + 'ex'); mjx.setAttribute('height', (w / W) * height + 'ex'); @@ -902,7 +923,7 @@ export class HoverRegion extends AbstractRegion { * @returns {[number, number]} The position in viewport coordinates */ protected xy(node: Element): number[] { - const P = DOMPoint.fromPoint({ x: 0, y: 0 }).matrixTransform( + const P = new DOMPoint().matrixTransform( (node as SVGGraphicsElement).getCTM().inverse() ); return [-P.x, -P.y]; diff --git a/ts/a11y/explorer/TreeExplorer.ts b/ts/a11y/explorer/TreeExplorer.ts index e432413e0..fd869f6a3 100644 --- a/ts/a11y/explorer/TreeExplorer.ts +++ b/ts/a11y/explorer/TreeExplorer.ts @@ -35,7 +35,6 @@ export class AbstractTreeExplorer extends AbstractExplorer { public pool: ExplorerPool, public region: Region, protected node: HTMLElement, - protected mml: HTMLElement ) { super(document, pool, null, node); } From dbeb390cbe00851ce9df8c7008208ed143654eb4 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Mon, 3 Aug 2026 12:43:41 -0400 Subject: [PATCH 04/13] Fix up mouse magnification handling --- ts/a11y/explorer.ts | 2 +- ts/a11y/explorer/MouseExplorer.ts | 135 +++++++++++++++++++++--------- ts/a11y/explorer/Region.ts | 4 +- ts/output/chtml/Wrappers/mtd.ts | 4 + 4 files changed, 103 insertions(+), 42 deletions(-) diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 59fa717b4..79b6c3e0b 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -402,7 +402,7 @@ export function ExplorerMathDocumentMixin< * Styles to add for speech */ public static speechStyles: StyleJson = { - 'mjx-container[has-speech="true"]': { + 'mjx-container /* explorers */': { position: 'relative', cursor: 'default', }, diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index 45070a786..311ceaa2d 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -115,30 +115,57 @@ export abstract class Hoverer extends AbstractMouseExplorer { protected item: ExplorerMathItem = null ) { super(document, pool, region, node); + const top = this.node.querySelector('[data-semantic-structure]') || this.node; + this.topBBox = top.getBoundingClientRect(); + this.nodeBBox = this.node.getBoundingClientRect(); + } + + protected current: HTMLElement; + protected listener = this.MouseMove.bind(this); + protected listening: boolean = false; + protected topBBox: DOMRect; + protected nodeBBox: DOMRect; + + protected inBBox(x: number, y:number, bbox: DOMRect) { + const {left, right, top, bottom} = bbox; + return x >= left && x <= right && y >=top && y <= bottom; } /** * @override */ public MouseOut(event: MouseEvent) { - this.highlighter.unhighlight(); - this.region.Hide(); - super.MouseOut(event); + if (!this.inBBox(event.x, event.y, this.topBBox)) { + this.highlighter.unhighlight(); + this.region.Hide(); + super.MouseOut(event); + this.current = null; + } + if (!this.inBBox(event.x, event.y, this.nodeBBox)) { + this.node.removeEventListener('mousemove', this.listener); + this.listening = false; + } + } + + public MouseMove(event: MouseEvent) { + const target = event.target as HTMLElement; + const node = this.findClicked(target, event.x, event.y); + if (node && node !== this.current) { + this.current = node; + this.highlighter.unhighlight(); + this.display(node, this.nodeAccess(node)); + } } /** * @override */ public MouseOver(event: MouseEvent) { - super.MouseOver(event); - const target = event.target as HTMLElement; - const [node, kind] = this.getNode(target); - if (!node) { - return; + if (!this.listening && this.inBBox(event.x, event.y, this.nodeBBox)) { + super.MouseOver(event); + this.node.addEventListener('mousemove', this.listener); + this.listening = true; } - this.highlighter.unhighlight(); - this.highlighter.highlight([node]); - this.display(node, kind); } /** @@ -146,40 +173,65 @@ export abstract class Hoverer extends AbstractMouseExplorer { * @param {T} kind The target kind to update */ protected display(node: HTMLElement, kind: T) { + this.highlighter.highlight([node]); this.region.Update(kind); this.region.Show(node); } - /** - * Retrieves the closest node on which the node query fires. Thereby closest - * is defined as: - * 1. The node or its ancestor on which the query is true. - * 2. In case 1 does not exist the left-most child on which query is true. - * 3. Otherwise fails. - * - * @param {HTMLElement} node The node on which the mouse event fired. - * @returns {[HTMLElement, T]} Node and output pair if successful. - */ - public getNode(node: HTMLElement): [HTMLElement, T] { - const original = node; - while (node && node !== this.node) { - if (this.nodeQuery(node)) { - return [node, this.nodeAccess(node)]; - } - node = node.parentNode as HTMLElement; + protected findClicked( + node: HTMLElement, + x: number, + y: number, + skip: HTMLElement[] = [], + icon: HTMLElement = null + ): HTMLElement { + let found = null; + // + // Check if the click is on the info icon and return that if it is. + // + if (icon && (icon === node || icon.contains(node))) { + return icon; } - node = original; - while (node) { - if (this.nodeQuery(node)) { - return [node, this.nodeAccess(node)]; + // + // For SVG, look through the tree to find the element whose bounding box + // contains the click (x,y) position. + // + let clicked = this.node; + while (clicked) { + if (clicked.matches('[data-semantic-id]')) { + found = clicked; // could be this node, but check if (x,y) is in a child + } + const nodes = Array.from(clicked.childNodes) as HTMLElement[]; + clicked = null; + for (let child of nodes) { + // + // Skip text or comment nodes + // + if (child.nodeName.charAt(0) === '#') { + continue; + } + // + // Move inside nodes used for tables with labels + // (for HTML they have 0 height and for SVG they are huge) + // + if ( + child.nodeName.toLowerCase() === 'mjx-labels' || + child.hasAttribute?.('data-table') || + child.hasAttribute?.('data-labels') + ) { + child = child.firstChild as HTMLElement; + } + if ( + !skip.includes(child) && + child.nodeName.toLowerCase() !== 'rect' && + this.inBBox(x, y, child.getBoundingClientRect() as DOMRect) + ) { + clicked = child; + break; + } } - 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]; + return found; } } @@ -239,9 +291,14 @@ export class ContentHoverer extends Hoverer { ); } + /** + * @override + */ display(node: HTMLElement) { this.item.parseSemanticNodes(); - this.region.splitNodes = this.item.getSplitNodes(node); + let parts = this.region.splitNodes = this.item.getSplitNodes(node); + parts = this.highlighter.encloseNodes([...parts], this.node); + this.highlighter.highlight(parts); this.region.Show(node); } } diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index b80ee80d0..ad66e20d9 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -812,7 +812,7 @@ export class HoverRegion extends AbstractRegion { if (math.nodeName === 'MJX-BBOX') { math = math.nextSibling; } - mjx = math.cloneNode(false); + mjx = math.cloneNode(false) as HTMLElement; const enclosed = this.splitNodes; math.nodeName === 'svg' ? this.svgClone(node, enclosed, mjx, container) @@ -898,7 +898,7 @@ export class HoverRegion extends AbstractRegion { ) { mjx.innerHTML = ''; mjx.appendChild(container.cloneNode(true).firstChild); - mjx.querySelector('.mjx-selected').setAttribute('data-mjx-clone', 'true'); + mjx.querySelector('.mjx-selected')?.setAttribute('data-mjx-clone', 'true'); mjx.querySelector('[data-sre-highlighter-added]')?.remove(); return; } diff --git a/ts/output/chtml/Wrappers/mtd.ts b/ts/output/chtml/Wrappers/mtd.ts index 98c42e7ba..f482ecf8e 100644 --- a/ts/output/chtml/Wrappers/mtd.ts +++ b/ts/output/chtml/Wrappers/mtd.ts @@ -148,6 +148,10 @@ export const ChtmlMtd = (function (): ChtmlMtdClass { 'mjx-mtable > * > mjx-itable > *:last-child > mjx-mtd': { 'padding-bottom': 0, }, + 'mjx-math > * > mjx-mtd': {// for magnifier when table node is not included + 'padding-top': 0, + 'padding-bottom': 0, + }, 'mjx-tstrut': { display: 'inline-block', height: '1em', From 2613951907726d9690373516c55413530da145b1 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 4 Aug 2026 06:35:47 -0400 Subject: [PATCH 05/13] Move common functions to Explorer.ts; fix key mangifier to respond to clicks --- ts/a11y/explorer/Explorer.ts | 77 ++++++++++++++++++++ ts/a11y/explorer/KeyExplorer.ts | 82 ++++++---------------- ts/a11y/explorer/MouseExplorer.ts | 113 +++++++++--------------------- 3 files changed, 133 insertions(+), 139 deletions(-) diff --git a/ts/a11y/explorer/Explorer.ts b/ts/a11y/explorer/Explorer.ts index 898460ffe..9b8f93bdb 100644 --- a/ts/a11y/explorer/Explorer.ts +++ b/ts/a11y/explorer/Explorer.ts @@ -276,4 +276,81 @@ export class AbstractExplorer implements Explorer { AbstractExplorer.stopEvent(event); } } + + /** + * @param {number} x The x-coordinate of the point to test + * @param {number} y The y-coordinate of the point to test + * @param {DOMRect} bbox The bounding box to test + * @returns {boolean} True if (x,y) is inside the bounding box + */ + protected inBBox(x: number, y:number, bbox: DOMRect): boolean { + const {left, right, top, bottom} = bbox; + return x >= left && x <= right && y >=top && y <= bottom; + } + + /** + * Find the smallest item in the expression's DOM tree that contains am event's point. + * + * @param {MouseEvent} event The event whose (x,y) is to be used + * @param {(node:HTMLElement)=>boolean} query A test for which nodes to accept + * @param {HTMLElement[]} skip Optional list of nodes to ignore + * @param {HTMLElement} icon The info icon, if there is one + * @returns {HTMLElement} The smallest matching element + * containing the event's point + */ + protected nodeAtXY( + event: MouseEvent, + query: (node: HTMLElement) => boolean, + skip: HTMLElement[] = [], + icon: HTMLElement = null + ): HTMLElement { + const {x, y, target} = event; + let found = null; + // + // Check if the click is on the info icon and return that if it is. + // + if (icon && (icon === target || icon.contains(target as HTMLElement))) { + return icon; + } + // + // For SVG, look through the tree to find the element whose bounding box + // contains the click (x,y) position. + // + let clicked = this.node; + while (clicked) { + if (query(clicked)) { + found = clicked; // could be this node, but check if (x,y) is in a child + } + const nodes = Array.from(clicked.childNodes) as HTMLElement[]; + clicked = null; + for (let child of nodes) { + // + // Skip text or comment nodes + // + if (child.nodeName.charAt(0) === '#') { + continue; + } + // + // Move inside nodes used for tables with labels + // (for HTML they have 0 height and for SVG they are huge) + // + if ( + child.nodeName.toLowerCase() === 'mjx-labels' || + child.hasAttribute?.('data-table') || + child.hasAttribute?.('data-labels') + ) { + child = child.firstChild as HTMLElement; + } + if ( + !skip.includes(child) && + child.nodeName.toLowerCase() !== 'rect' && + this.inBBox(x, y, child.getBoundingClientRect() as DOMRect) + ) { + clicked = child; + break; + } + } + } + return found; + } } diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 900a48ad1..d914f666f 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -444,10 +444,11 @@ export class SpeechExplorer // // Get the speech element that was clicked // - const clicked = this.findClicked( - event.target as HTMLElement, - event.x, - event.y + const clicked = this.nodeAtXY( + event, + (node) => node.matches('[data-speech-node]'), + [this.speech, this.img], + this.document.infoIcon ); // // If it is the info icon, top the event and let the click handler process it @@ -489,10 +490,11 @@ export class SpeechExplorer // // Get the speech element that was clicked // - const clicked = this.findClicked( - event.target as HTMLElement, - event.x, - event.y + const clicked = this.nodeAtXY( + event, + (node) => node.matches('[data-speech-node]'), + [this.speech, this.img], + this.document.infoIcon ); // // If it was the info icon, open the help dialog @@ -503,6 +505,16 @@ export class SpeechExplorer return; } // + // If we have a key magnifier but no speech or Braille, show the clicked node + // + if (clicked && this.clicked) { + const {speech, braille, keyMagnifier} = this.document.options.a11y; + if (!speech && !braille && keyMagnifier) { + this.setCurrent(clicked); + return; + } + } + // // If the node contains the clicked element, // don't propagate the event // focus on the clicked element when focusin occurs @@ -1545,58 +1557,6 @@ export class SpeechExplorer 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 && - 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; - } - /** * @param {HTMLElement} node The node to test for having an href * @returns {boolean} True if the node has a link, false otherwise @@ -1718,7 +1678,6 @@ export class SpeechExplorer * @override */ public async Start() { - this.item.parseSemanticNodes(); // // If we aren't attached or already active, return // @@ -1748,6 +1707,7 @@ export class SpeechExplorer // speech node (or just use the top-level node), then set the // current node (which creates the speech) and start the explorer. // + this.item.parseSemanticNodes(); const node = this.findStartNode(); this.setCurrent(node || this.rootNode(), !node); super.Start(); diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index 311ceaa2d..bab0a9951 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -91,6 +91,31 @@ export abstract class AbstractMouseExplorer * @template T */ export abstract class Hoverer extends AbstractMouseExplorer { + /** + * The currently selected element + */ + protected current: HTMLElement; + + /** + * The mousemove event handler (added after a mouseover) + */ + protected listener = this.MouseMove.bind(this); + + /** + * True if the mousemove listener has been added + */ + protected listening: boolean = false; + + /** + * The bounding box for the box with data-semantic-structure + */ + protected topBBox: DOMRect; + + /** + * The bounding box for the top-level node + */ + protected nodeBBox: DOMRect; + /** * @class * @augments {AbstractMouseExplorer} @@ -120,17 +145,6 @@ export abstract class Hoverer extends AbstractMouseExplorer { this.nodeBBox = this.node.getBoundingClientRect(); } - protected current: HTMLElement; - protected listener = this.MouseMove.bind(this); - protected listening: boolean = false; - protected topBBox: DOMRect; - protected nodeBBox: DOMRect; - - protected inBBox(x: number, y:number, bbox: DOMRect) { - const {left, right, top, bottom} = bbox; - return x >= left && x <= right && y >=top && y <= bottom; - } - /** * @override */ @@ -147,16 +161,6 @@ export abstract class Hoverer extends AbstractMouseExplorer { } } - public MouseMove(event: MouseEvent) { - const target = event.target as HTMLElement; - const node = this.findClicked(target, event.x, event.y); - if (node && node !== this.current) { - this.current = node; - this.highlighter.unhighlight(); - this.display(node, this.nodeAccess(node)); - } - } - /** * @override */ @@ -168,6 +172,15 @@ export abstract class Hoverer extends AbstractMouseExplorer { } } + public MouseMove(event: MouseEvent) { + const node = this.nodeAtXY(event, this.nodeQuery); + if (node && node !== this.current) { + this.current = node; + this.highlighter.unhighlight(); + this.display(node, this.nodeAccess(node)); + } + } + /** * @param {HTMLElement} node The target node to update * @param {T} kind The target kind to update @@ -177,62 +190,6 @@ export abstract class Hoverer extends AbstractMouseExplorer { this.region.Update(kind); this.region.Show(node); } - - protected findClicked( - node: HTMLElement, - x: number, - y: number, - skip: HTMLElement[] = [], - icon: HTMLElement = null - ): HTMLElement { - let found = null; - // - // Check if the click is on the info icon and return that if it is. - // - if (icon && (icon === node || icon.contains(node))) { - return icon; - } - // - // For SVG, look through the tree to find the element whose bounding box - // contains the click (x,y) position. - // - let clicked = this.node; - while (clicked) { - if (clicked.matches('[data-semantic-id]')) { - found = clicked; // could be this node, but check if (x,y) is in a child - } - const nodes = Array.from(clicked.childNodes) as HTMLElement[]; - clicked = null; - for (let child of nodes) { - // - // Skip text or comment nodes - // - if (child.nodeName.charAt(0) === '#') { - continue; - } - // - // Move inside nodes used for tables with labels - // (for HTML they have 0 height and for SVG they are huge) - // - if ( - child.nodeName.toLowerCase() === 'mjx-labels' || - child.hasAttribute?.('data-table') || - child.hasAttribute?.('data-labels') - ) { - child = child.firstChild as HTMLElement; - } - if ( - !skip.includes(child) && - child.nodeName.toLowerCase() !== 'rect' && - this.inBBox(x, y, child.getBoundingClientRect() as DOMRect) - ) { - clicked = child; - break; - } - } - } - return found; - } } /** @@ -294,7 +251,7 @@ export class ContentHoverer extends Hoverer { /** * @override */ - display(node: HTMLElement) { + protected display(node: HTMLElement) { this.item.parseSemanticNodes(); let parts = this.region.splitNodes = this.item.getSplitNodes(node); parts = this.highlighter.encloseNodes([...parts], this.node); From 6f07f69579394d1a80e20b27179f0adc20584f2e Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 4 Aug 2026 18:58:48 -0400 Subject: [PATCH 06/13] Fix clicking on enclosures for key explorers --- ts/a11y/explorer/Explorer.ts | 15 +++++++++------ ts/a11y/explorer/Highlighter.ts | 3 +++ ts/a11y/explorer/KeyExplorer.ts | 5 +++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ts/a11y/explorer/Explorer.ts b/ts/a11y/explorer/Explorer.ts index 9b8f93bdb..d97a27494 100644 --- a/ts/a11y/explorer/Explorer.ts +++ b/ts/a11y/explorer/Explorer.ts @@ -22,7 +22,7 @@ */ import { A11yDocument, Region } from './Region.js'; -import { Highlighter } from './Highlighter.js'; +import { Highlighter, ATTR } from './Highlighter.js'; import type { ExplorerPool } from './ExplorerPool.js'; @@ -327,7 +327,10 @@ export class AbstractExplorer implements Explorer { // // Skip text or comment nodes // - if (child.nodeName.charAt(0) === '#') { + if ( + child.nodeName.charAt(0) === '#' || + child.hasAttribute?.(ATTR.ADDED) + ) { continue; } // @@ -336,15 +339,15 @@ export class AbstractExplorer implements Explorer { // if ( child.nodeName.toLowerCase() === 'mjx-labels' || - child.hasAttribute?.('data-table') || - child.hasAttribute?.('data-labels') + child.hasAttribute?.('data-table') || + child.hasAttribute?.('data-labels') ) { child = child.firstChild as HTMLElement; } if ( !skip.includes(child) && - child.nodeName.toLowerCase() !== 'rect' && - this.inBBox(x, y, child.getBoundingClientRect() as DOMRect) + child.nodeName.toLowerCase() !== 'rect' && + this.inBBox(x, y, child.getBoundingClientRect() as DOMRect) ) { clicked = child; break; diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 82c1074e9..2ee8bb73d 100644 --- a/ts/a11y/explorer/Highlighter.ts +++ b/ts/a11y/explorer/Highlighter.ts @@ -390,6 +390,7 @@ class SvgHighlighter extends AbstractHighlighter { part.getAttribute('transform') ); rect.setAttribute(ATTR.BBOX, 'true'); + rect.setAttribute(ATTR.ADDED, 'true'); part.parentNode.insertBefore(rect, part); return rect; } @@ -490,6 +491,8 @@ class ChtmlHighlighter extends AbstractHighlighter { enclosure.style.left = x - base.left + 'px'; enclosure.style.top = y - h - base.top + 'px'; enclosure.style.position = 'absolute'; + enclosure.setAttribute(ATTR.BBOX, 'true'); + enclosure.setAttribute(ATTR.ADDED, 'true'); node.prepend(enclosure); return enclosure; } diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index d914f666f..1a5044aea 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -26,6 +26,7 @@ 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 { ATTR } from './Highlighter.js'; import { MmlNode } from '../../core/MmlTree/MmlNode.js'; import { honk, SemAttr } from '../speech/SpeechUtil.js'; import { GeneratorPool } from '../speech/GeneratorPool.js'; @@ -463,7 +464,7 @@ export class SpeechExplorer // otherwise record the click for the focusin handler // document.getSelection()?.removeAllRanges(); - if ((event.target as HTMLElement).getAttribute('sre-highlighter-added')) { + if ((event.target as HTMLElement).getAttribute(ATTR.ADDED)) { this.refocus = clicked; } else { this.clicked = clicked; @@ -1091,7 +1092,7 @@ export class SpeechExplorer const parts = [...this.item.getSplitNodes(this.current)]; this.highlighter.encloseNodes(parts, this.node); for (const part of parts) { - if (!part.getAttribute('data-sre-enclosed')) { + if (!part.getAttribute(ATTR.ENCLOSED)) { part.classList.add('mjx-selected'); } } From 23829db240d650f2b50e998138d351fa3e2b8547 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Wed, 5 Aug 2026 06:31:04 -0400 Subject: [PATCH 07/13] More fixes for nmouse explorers --- ts/a11y/explorer/ExplorerPool.ts | 23 +++++++----- ts/a11y/explorer/MouseExplorer.ts | 61 ++++++++++++++++++++----------- ts/a11y/explorer/Region.ts | 7 +++- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/ts/a11y/explorer/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts index e90e99762..a1c7c1906 100644 --- a/ts/a11y/explorer/ExplorerPool.ts +++ b/ts/a11y/explorer/ExplorerPool.ts @@ -26,7 +26,7 @@ import type { ExplorerMathDocument, ExplorerMathItem } from '../explorer.js'; import { Explorer } from './Explorer.js'; import { SpeechExplorer } from './KeyExplorer.js'; -import * as me from './MouseExplorer.js'; +import { ValueHoverer, ContentHoverer, FlameHoverer } from './MouseExplorer.js'; import { TreeColorer, FlameColorer } from './TreeExplorer.js'; import { Highlighter, getHighlighter } from './Highlighter.js'; @@ -105,40 +105,43 @@ const allExplorers: { [options: string]: ExplorerInit } = { return explorer; }, mouseMagnifier: (doc, pool, node, item) => - me.ContentHoverer.create( + ContentHoverer.create( doc, pool, doc.explorerRegions.magnifier, node, item ), - hover: (doc, pool, node) => me.FlameHoverer.create(doc, pool, null, node), - infoType: (doc, pool, node) => - me.ValueHoverer.create( + hover: (doc, pool, node) => FlameHoverer.create(doc, pool, null, node), + infoType: (doc, pool, node, item) => + ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip1, node, + item, 'data-semantic-type' ), - infoRole: (doc, pool, node) => - me.ValueHoverer.create( + infoRole: (doc, pool, node, item) => + ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip2, node, + item, 'data-semantic-role' ), - infoPrefix: (doc, pool, node) => - me.ValueHoverer.create( + infoPrefix: (doc, pool, node, item) => + ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip3, node, + item, 'data-semantic-prefix-none', ), flame: (doc, pool, node) => FlameColorer.create(doc, pool, null, node), - treeColoring: (doc, pool, node) => TreeColorer.create(doc, pool, null, node), + treeColoring: (doc, pool, node, item) => TreeColorer.create(doc, pool, null, node, item), }; /** diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index bab0a9951..6ca4c8663 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -116,6 +116,11 @@ export abstract class Hoverer extends AbstractMouseExplorer { */ protected nodeBBox: DOMRect; + /** + * used to tell if regino has splitNodes + */ + protected isHover = this.region instanceof HoverRegion; + /** * @class * @augments {AbstractMouseExplorer} @@ -135,9 +140,9 @@ export abstract class Hoverer extends AbstractMouseExplorer { public pool: ExplorerPool, public region: Region, protected node: HTMLElement, + protected item: ExplorerMathItem = null, protected nodeQuery: (node: HTMLElement) => boolean, - protected nodeAccess: (node: HTMLElement) => T, - protected item: ExplorerMathItem = null + protected nodeAccess: (node: HTMLElement) => T ) { super(document, pool, region, node); const top = this.node.querySelector('[data-semantic-structure]') || this.node; @@ -186,8 +191,16 @@ export abstract class Hoverer extends AbstractMouseExplorer { * @param {T} kind The target kind to update */ protected display(node: HTMLElement, kind: T) { - this.highlighter.highlight([node]); - this.region.Update(kind); + this.item.parseSemanticNodes(); + let parts = this.item.getSplitNodes(node); + if (this.isHover) { + (this.region as HoverRegion).splitNodes = parts; + } + parts = this.highlighter.encloseNodes([...parts], this.node); + this.highlighter.highlight(parts); + if (typeof kind === 'string') { + this.region.Update(kind); + } this.region.Show(node); } } @@ -207,13 +220,15 @@ export class ValueHoverer extends Hoverer { pool: ExplorerPool, region: ToolTip, node: HTMLElement, - attr: string, + item: ExplorerMathItem, + attr: string ) { super( document, pool, region, node, + item, (x) => x.hasAttribute?.(attr), (x) => x.getAttribute?.(attr) ); @@ -233,7 +248,7 @@ export class ContentHoverer extends Hoverer { protected constructor( document: A11yDocument, pool: ExplorerPool, - public region: HoverRegion, + region: HoverRegion, node: HTMLElement, item: ExplorerMathItem, ) { @@ -242,22 +257,11 @@ export class ContentHoverer extends Hoverer { pool, region, node, + item, (x) => x.hasAttribute?.('data-semantic-id'), - (x) => x, - item + (x) => x ); } - - /** - * @override - */ - protected display(node: HTMLElement) { - this.item.parseSemanticNodes(); - let parts = this.region.splitNodes = this.item.getSplitNodes(node); - parts = this.highlighter.encloseNodes([...parts], this.node); - this.highlighter.highlight(parts); - this.region.Show(node); - } } /** @@ -274,15 +278,30 @@ export class FlameHoverer extends Hoverer { document: A11yDocument, pool: ExplorerPool, _ignore: any, - node: HTMLElement + node: HTMLElement, + item: ExplorerMathItem ) { super( document, pool, new DummyRegion(document), node, - (x) => this.highlighter.isMactionNode(x), + item, + (x) => x.hasAttribute('data-collapsible'), () => {} ); } + + display(node: HTMLElement) { + const id = node.getAttribute('data-collapse-id'); + if (id) { + node = this.node.querySelector(`#${id}`); + } + let parts: HTMLElement[] = node.hasAttribute('data-collapse-group') + ? this.highlighter.getMactionGroup(this.node, node) + : [node]; + parts = this.highlighter.encloseNodes([...parts], this.node); + this.highlighter.highlight(parts); + this.region.Show(node); + } } diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index ad66e20d9..1177d84b2 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -669,6 +669,11 @@ export class HoverRegion extends AbstractRegion { */ protected static className = 'MJX_HoverRegion'; + /** + * the split nodes for the math item + */ + public splitNodes: any; + /** * @override */ @@ -774,8 +779,6 @@ export class HoverRegion extends AbstractRegion { this.inner.style.backgroundColor = ''; } - public splitNodes: any; - /** * @override */ From 86e87279607837ea6e489976ee7c7057eaaf4cb5 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Wed, 5 Aug 2026 19:15:01 -0400 Subject: [PATCH 08/13] Fixes for Prettier --- ts/a11y/explorer.ts | 12 +++++++----- ts/a11y/explorer/Explorer.ts | 8 ++++---- ts/a11y/explorer/ExplorerPool.ts | 17 ++++++----------- ts/a11y/explorer/KeyExplorer.ts | 2 +- ts/a11y/explorer/MouseExplorer.ts | 13 ++++++++++--- ts/a11y/explorer/Region.ts | 16 ++++++++-------- ts/a11y/explorer/TreeExplorer.ts | 2 +- ts/a11y/explorer/__locales__/de.json | 2 +- ts/a11y/explorer/__locales__/en.json | 2 +- ts/a11y/speech/StructureUtil.ts | 12 ++++++++++-- ts/a11y/sre/require.mjs | 2 +- ts/core/Tree/Node.ts | 8 ++++++-- ts/output/chtml/Wrappers/mtd.ts | 3 ++- 13 files changed, 58 insertions(+), 41 deletions(-) diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 79b6c3e0b..3051521b6 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -130,7 +130,7 @@ export interface ExplorerMathItem extends HTMLMATHITEM { * @template B The MathItem class to extend */ export function ExplorerMathItemMixin>( - BaseMathItem: B, + BaseMathItem: B ): Constructor & B { return class BaseClass extends BaseMathItem { /** @@ -298,11 +298,13 @@ export function ExplorerMathItemMixin>( if (!id) { return [node]; } - const nodes = (this.semanticNodes.get(id) ?? [id]).map( - (nid: string) => Array.from( - this.typesetRoot.querySelectorAll(`[data-semantic-id="${nid}"]`) + const nodes = (this.semanticNodes.get(id) ?? [id]) + .map((nid: string) => + Array.from( + this.typesetRoot.querySelectorAll(`[data-semantic-id="${nid}"]`) + ) ) - ).flat() as HTMLElement[]; + .flat() as HTMLElement[]; return nodes; } }; diff --git a/ts/a11y/explorer/Explorer.ts b/ts/a11y/explorer/Explorer.ts index d97a27494..1fdf2e306 100644 --- a/ts/a11y/explorer/Explorer.ts +++ b/ts/a11y/explorer/Explorer.ts @@ -283,9 +283,9 @@ export class AbstractExplorer implements Explorer { * @param {DOMRect} bbox The bounding box to test * @returns {boolean} True if (x,y) is inside the bounding box */ - protected inBBox(x: number, y:number, bbox: DOMRect): boolean { - const {left, right, top, bottom} = bbox; - return x >= left && x <= right && y >=top && y <= bottom; + protected inBBox(x: number, y: number, bbox: DOMRect): boolean { + const { left, right, top, bottom } = bbox; + return x >= left && x <= right && y >= top && y <= bottom; } /** @@ -304,7 +304,7 @@ export class AbstractExplorer implements Explorer { skip: HTMLElement[] = [], icon: HTMLElement = null ): HTMLElement { - const {x, y, target} = event; + const { x, y, target } = event; let found = null; // // Check if the click is on the info icon and return that if it is. diff --git a/ts/a11y/explorer/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts index a1c7c1906..722d9681a 100644 --- a/ts/a11y/explorer/ExplorerPool.ts +++ b/ts/a11y/explorer/ExplorerPool.ts @@ -84,7 +84,7 @@ type ExplorerInit = ( doc: ExplorerMathDocument, pool: ExplorerPool, node: HTMLElement, - item: ExplorerMathItem, + item: ExplorerMathItem ) => Explorer; /** @@ -105,13 +105,7 @@ const allExplorers: { [options: string]: ExplorerInit } = { return explorer; }, mouseMagnifier: (doc, pool, node, item) => - ContentHoverer.create( - doc, - pool, - doc.explorerRegions.magnifier, - node, - item - ), + ContentHoverer.create(doc, pool, doc.explorerRegions.magnifier, node, item), hover: (doc, pool, node) => FlameHoverer.create(doc, pool, null, node), infoType: (doc, pool, node, item) => ValueHoverer.create( @@ -138,10 +132,11 @@ const allExplorers: { [options: string]: ExplorerInit } = { doc.explorerRegions.tooltip3, node, item, - 'data-semantic-prefix-none', + 'data-semantic-prefix-none' ), flame: (doc, pool, node) => FlameColorer.create(doc, pool, null, node), - treeColoring: (doc, pool, node, item) => TreeColorer.create(doc, pool, null, node, item), + treeColoring: (doc, pool, node, item) => + TreeColorer.create(doc, pool, null, node, item), }; /** @@ -178,7 +173,7 @@ export class ExplorerPool { /** * The corresponding Mathml node as a string. */ -// protected mml: string; + // protected mml: string; /** * The primary highlighter shared by all explorers. diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 1a5044aea..7cd3c5124 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -509,7 +509,7 @@ export class SpeechExplorer // If we have a key magnifier but no speech or Braille, show the clicked node // if (clicked && this.clicked) { - const {speech, braille, keyMagnifier} = this.document.options.a11y; + const { speech, braille, keyMagnifier } = this.document.options.a11y; if (!speech && !braille && keyMagnifier) { this.setCurrent(clicked); return; diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index 6ca4c8663..5bebda43e 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -21,7 +21,13 @@ * @author v.sorge@mathjax.org (Volker Sorge) */ -import { A11yDocument, DummyRegion, Region, HoverRegion, ToolTip } from './Region.js'; +import { + A11yDocument, + DummyRegion, + Region, + HoverRegion, + ToolTip, +} from './Region.js'; import { Explorer, AbstractExplorer } from './Explorer.js'; import { ExplorerPool } from './ExplorerPool.js'; import type { ExplorerMathItem } from '../explorer.js'; @@ -145,7 +151,8 @@ export abstract class Hoverer extends AbstractMouseExplorer { protected nodeAccess: (node: HTMLElement) => T ) { super(document, pool, region, node); - const top = this.node.querySelector('[data-semantic-structure]') || this.node; + const top = + this.node.querySelector('[data-semantic-structure]') || this.node; this.topBBox = top.getBoundingClientRect(); this.nodeBBox = this.node.getBoundingClientRect(); } @@ -250,7 +257,7 @@ export class ContentHoverer extends Hoverer { pool: ExplorerPool, region: HoverRegion, node: HTMLElement, - item: ExplorerMathItem, + item: ExplorerMathItem ) { super( document, diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index 1177d84b2..30a3f07fe 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -834,11 +834,7 @@ export class HoverRegion extends AbstractRegion { * @param {Element[]} enclosed The elements to be cloned * @param {HTMLElement} mjx The container for the clones */ - protected chtmlClone( - node: Element, - enclosed: Element[], - mjx: HTMLElement - ) { + protected chtmlClone(node: Element, enclosed: Element[], mjx: HTMLElement) { const included = new Set(); for (const child of enclosed) { const id = child.getAttribute('data-semantic-id'); @@ -875,9 +871,11 @@ export class HoverRegion extends AbstractRegion { if (rect?.getAttribute('data-sre-highlighter-added')) { const bbox = rect.getBBox(); const [X, Y] = this.xy(rect); - x = X; y = Y + bbox.y; + [x, y] = [X, Y + bbox.y]; if (left === undefined || x < left) left = x; - if (right === undefined || x + bbox.width > right) right = x + bbox.width; + if (right === undefined || x + bbox.width > right) { + right = x + bbox.width; + } top ??= bbox.height + bbox.y + Y; bot = y; } @@ -901,7 +899,9 @@ export class HoverRegion extends AbstractRegion { ) { mjx.innerHTML = ''; mjx.appendChild(container.cloneNode(true).firstChild); - mjx.querySelector('.mjx-selected')?.setAttribute('data-mjx-clone', 'true'); + mjx + .querySelector('.mjx-selected') + ?.setAttribute('data-mjx-clone', 'true'); mjx.querySelector('[data-sre-highlighter-added]')?.remove(); return; } diff --git a/ts/a11y/explorer/TreeExplorer.ts b/ts/a11y/explorer/TreeExplorer.ts index fd869f6a3..119228121 100644 --- a/ts/a11y/explorer/TreeExplorer.ts +++ b/ts/a11y/explorer/TreeExplorer.ts @@ -34,7 +34,7 @@ export class AbstractTreeExplorer extends AbstractExplorer { public document: A11yDocument, public pool: ExplorerPool, public region: Region, - protected node: HTMLElement, + protected node: HTMLElement ) { super(document, pool, null, node); } diff --git a/ts/a11y/explorer/__locales__/de.json b/ts/a11y/explorer/__locales__/de.json index 5223cd92c..4f2deb526 100644 --- a/ts/a11y/explorer/__locales__/de.json +++ b/ts/a11y/explorer/__locales__/de.json @@ -5,7 +5,7 @@ "Help/Text2": "

Über das MathJax-Kontextmenü können Sie die Sprach- oder Braille-Ausgabe für mathematische Ausdrücke aktivieren oder deaktivieren, die Sprache für die Sprachausgabe festlegen sowie weitere Funktionen von MathJax einstellen. Insbesondere können Sie im Untermenü „Explorer“ festlegen, wie die mathematischen Ausdrücke auf der Seite gekennzeichnet werden sollen (z. B. durch die Ansage „Mathematik“, wenn der Ausdruck vorgelesen wird) und ob eine Meldung darüber angezeigt werden soll, dass der Buchstabe „h“ dieses Dialogfeld öffnet. Wenn Sie die Sprachausgabe und die Braille-Anzeige deaktivieren, werden der Ausdrucks-Explorer, dessen Highlighting und das Hilfe-Symbol deaktiviert.

\n\n

Die Unterstützung für taktile Braille-Geräte variiert je nach Screenreader, Browser und Betriebssystem. Wenn Sie ein Braille-Ausgabegerät verwenden, müssen Sie möglicherweise die Option „Mit Sprachausgabe kombinieren“ im Untermenü „Braille“ des Kontextmenüs auswählen, um anstelle der Sprachausgabe auf Ihrem Braille-Gerät eine Nemeth- oder Euro-Braille-Ausgabe zu erhalten. %1

\n\n

Das Kontextmenü bietet außerdem Optionen zum Anzeigen oder Kopieren einer MathML-Version des Ausdrucks oder seines ursprünglichen Quellformats, zum Erstellen einer SVG-Version des Ausdrucks sowie zum Anzeigen verschiedener weiterer Informationen.

\n\n

Wenn Sie schließlich im Optionenmenü den Eintrag „MathML versteckt einbinden“ auswählen, wird die Sprach- und Braille-Generierung von MathJax deaktiviert und stattdessen visuell unsichtbares MathML verwendet, das von einigen Screenreadern vorgelesen werden kann, wobei diese Funktion nicht von allen Screenreadern und Betriebssystemen unterstützt wird. Durch Auswahl der Sprach- oder Braille-Generierung in den entsprechenden Untermenüs wird das unsichtbare MathML wieder entfernt.

\n\n

Weitere Hilfe finden Sie in der MathJax-Dokumentation zur Barrierefreiheit.

", "Help/Title": "Hilfe zum MathJax Expression Explorer", "Mac/Select": "oder die VoiceOver-Pfeiltasten, um einen Ausdruck auszuwählen", - "Mac/Title": "macOS und iOS mit VoiceOver", + "Mac/Title": "macOS und iOS mit VoiceOver", "JumpTo": "Springe zu Zeile %1 und Spalte", "PositionMarked": "Position markiert", "Unix/Select": "Orca sollte automatisch in den Fokusmodus wechseln. Ist dies nicht der Fall, kannst du den Fokusmodus mit der Tastenkombination „Orca+a“ ein- oder ausschalten. Beachte außerdem, dass du mit „Orca+Pfeiltasten“ Ausdrücke auch im Durchsichtmodus untersuchen kannst.", diff --git a/ts/a11y/explorer/__locales__/en.json b/ts/a11y/explorer/__locales__/en.json index 39ca9e662..9e13190ba 100644 --- a/ts/a11y/explorer/__locales__/en.json +++ b/ts/a11y/explorer/__locales__/en.json @@ -5,7 +5,7 @@ "Help/Text2": "

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. Turning off speech and Braille will disable the expression explorer, its highlighting, and its help icon.

\n\n

Support for tactile Braille devices varies across screen readers, browsers, and operative systems. If you are using a Braille output device, you may need to select the \"Combine with Speech\" option in the contextual menu's Braille submenu in order to obtain Nemeth or Euro Braille output rather than the speech text on your Braille device. %1

\n\n

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.

\n\n

Finally, selecting the \"Insert Hidden MathML\" item from the options submenu will turn of MathJax's speech and Braille generation and instead use visually hidden MathML that some screen readers can voice, though support for this is not universal across all screen readers and operating systems. Selecting speech or Braille generation in their submenus will remove the hidden MathML again.

\n\n

For more help, see the MathJax accessibility documentation.

", "Help/Title": "MathJax Expression Explorer Help", "Mac/Select": "or the VoiceOver arrow keys to select an expression", - "Mac/Title": "MacOS and iOS using VoiceOver", + "Mac/Title": "MacOS and iOS using VoiceOver", "JumpTo": "Jump to row %1 and column", "PositionMarked": "Position marked", "Unix/Select": "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", diff --git a/ts/a11y/speech/StructureUtil.ts b/ts/a11y/speech/StructureUtil.ts index d640f7229..af9edcafa 100644 --- a/ts/a11y/speech/StructureUtil.ts +++ b/ts/a11y/speech/StructureUtil.ts @@ -50,7 +50,11 @@ export class StructureUtil { * @param {ParentMap} map The map being built * @returns {ParentMap} The map of semantic ids to their nearset parent ids */ - protected static mapParents(node: MmlNode, id: string = '', map: ParentMap = new Map()): ParentMap { + protected static mapParents( + node: MmlNode, + id: string = '', + map: ParentMap = new Map() + ): ParentMap { const nid = node.attributes.get('data-semantic-id') as string; if (nid) { map.set(nid, id); @@ -90,7 +94,11 @@ export class StructureUtil { * @param {SemanticMap} map The map being built. * @returns {string[]} The semantic nodes outside the MathML subtree. */ - protected static mapExtras(tree: SexpTree, parents: ParentMap, map: SemanticMap): string[] { + protected static mapExtras( + tree: SexpTree, + parents: ParentMap, + map: SemanticMap + ): string[] { if (!Array.isArray(tree)) return [tree]; const id = tree[0] as string; const extra: string[] = []; diff --git a/ts/a11y/sre/require.mjs b/ts/a11y/sre/require.mjs index a45271169..f335a1e3b 100644 --- a/ts/a11y/sre/require.mjs +++ b/ts/a11y/sre/require.mjs @@ -1,2 +1,2 @@ -import {createRequire} from 'module'; +import { createRequire } from 'module'; global.require = createRequire(import.meta.url); diff --git a/ts/core/Tree/Node.ts b/ts/core/Tree/Node.ts index 8d72cfb54..8729d2da1 100644 --- a/ts/core/Tree/Node.ts +++ b/ts/core/Tree/Node.ts @@ -333,11 +333,15 @@ export abstract class AbstractNode< /** * @override */ - public walkTree(func: (node: N, data?: any) => boolean | void, data?: any, state: {continue: boolean} = {continue: true}): any { + public walkTree( + func: (node: N, data?: any) => boolean | void, + data?: any, + state: { continue: boolean } = { continue: true } + ): any { if (func(this as any as N, data)) { state.continue = false; return data; - }; + } for (const child of this.childNodes) { if (child && state.continue) { (child as unknown as AbstractNode).walkTree(func, data, state); diff --git a/ts/output/chtml/Wrappers/mtd.ts b/ts/output/chtml/Wrappers/mtd.ts index f482ecf8e..1a230905e 100644 --- a/ts/output/chtml/Wrappers/mtd.ts +++ b/ts/output/chtml/Wrappers/mtd.ts @@ -148,7 +148,8 @@ export const ChtmlMtd = (function (): ChtmlMtdClass { 'mjx-mtable > * > mjx-itable > *:last-child > mjx-mtd': { 'padding-bottom': 0, }, - 'mjx-math > * > mjx-mtd': {// for magnifier when table node is not included + 'mjx-math > * > mjx-mtd': { + // for magnifier when table node is not included 'padding-top': 0, 'padding-bottom': 0, }, From 90b97505cec23a777782ef39f6d89d069f8986c4 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Wed, 5 Aug 2026 19:20:29 -0400 Subject: [PATCH 09/13] Add ability for collapse to collapse extra nodes, and have hover and flame highlight everything --- ts/a11y/complexity.ts | 4 +- ts/a11y/complexity/collapse.ts | 95 ++++++++++++++++++++++------- ts/a11y/complexity/visitor.ts | 5 +- ts/a11y/explorer/Highlighter.ts | 46 +++++++++++--- ts/output/chtml/Wrappers/maction.ts | 9 +++ ts/output/svg/Wrappers/maction.ts | 9 +++ 6 files changed, 135 insertions(+), 33 deletions(-) diff --git a/ts/a11y/complexity.ts b/ts/a11y/complexity.ts index ce65fce45..534b23030 100644 --- a/ts/a11y/complexity.ts +++ b/ts/a11y/complexity.ts @@ -197,9 +197,11 @@ export function ComplexityMathDocumentMixin>( visitorOptions ); const computeComplexity = (math: ComplexityMathItem) => { + math.parseSemanticNodes(); math.initialID = this.complexityVisitor.visitTree( math.root, - math.initialID + math.initialID, + math.semanticNodes ); }; this.options.MathItem = ComplexityMathItemMixin< diff --git a/ts/a11y/complexity/collapse.ts b/ts/a11y/complexity/collapse.ts index 803b323a3..72854745a 100644 --- a/ts/a11y/complexity/collapse.ts +++ b/ts/a11y/complexity/collapse.ts @@ -28,6 +28,7 @@ import { } from '../../core/MmlTree/MmlNode.js'; import { PropertyList } from '../../core/Tree/Node.js'; import { ComplexityVisitor } from './visitor.js'; +import type { SemanticMap } from '../speech/StructureUtil.js'; /*==========================================================================*/ @@ -365,7 +366,7 @@ export class Collapse { * * @param {MmlNode} node The node to check * @param {number} complexity The current complexity of the node - * @returns {number} The revised complexity + * @returns {number} The revised complexity */ public check(node: MmlNode, complexity: number): number { const type = node.attributes.get('data-semantic-type') as string; @@ -384,7 +385,7 @@ export class Collapse { * @param {MmlNode} node The node to check * @param {number} complexity The current complexity of the node * @param {string} type The semantic type of the node - * @returns {number} The revised complexity + * @returns {number} The revised complexity */ protected defaultCheck( node: MmlNode, @@ -408,7 +409,7 @@ export class Collapse { * @param {MmlNode} node The node to check * @param {number} complexity The current complexity of the node * @param {string} text The text to use for the collapsed node - * @returns {number} The revised complexity for the collapsed node + * @returns {number} The revised complexity for the collapsed node */ protected recordCollapse( node: MmlNode, @@ -439,7 +440,7 @@ export class Collapse { * @param {MmlNode} node The node to check if its child is collapsible * @param {number} n The position of the child node to check * @param {number=} m The number of children node must have - * @returns {MmlNode|null} The child node that was collapsed (or null) + * @returns {MmlNode|null} The child node that was collapsed (or null) */ protected canUncollapse( node: MmlNode, @@ -466,7 +467,7 @@ export class Collapse { * @param {MmlNode} node The node to check * @param {number} n The position of the child node to check * @param {number=} m The number of children the node must have - * @returns {number} The updated complexity + * @returns {number} The updated complexity */ protected uncollapseChild( complexity: number, @@ -488,7 +489,7 @@ export class Collapse { /** * @param {MmlNode} node The node whose attribute is to be split * @param {string} id The name of the data-semantic attribute to split - * @returns {string[]} Array of ids in the attribute split at commas + * @returns {string[]} Array of ids in the attribute split at commas */ protected splitAttribute(node: MmlNode, id: string): string[] { return ((node.attributes.get('data-semantic-' + id) as string) || '').split( @@ -508,7 +509,7 @@ export class Collapse { /** * @param {MmlNode} node The node whose child text is needed * @param {string} id The (semantic) id of the child needed - * @returns {string} The text of the specified child node + * @returns {string} The text of the specified child node */ protected findChildText(node: MmlNode, id: string): string { const child = this.findChild(node, id); @@ -518,7 +519,7 @@ export class Collapse { /** * @param {MmlNode} node The node whose child is to be located * @param {string} id The (semantic) id of the child to be found - * @returns {MmlNode|null} The child node (or null if not found) + * @returns {MmlNode|null} The child node (or null if not found) */ protected findChild(node: MmlNode, id: string): MmlNode | null { if (!node || node.attributes.get('data-semantic-id') === id) return node; @@ -534,11 +535,16 @@ export class Collapse { /** * Add maction nodes to the nodes in the tree that can collapse * - * @param {MmlNode} node The root of the tree to check - * @param {number|null} id The initial id to use - * @returns {number} The initial id used + * @param {MmlNode} node The root of the tree to check + * @param {number|null} id The initial id to use + * @param {SemanticMap} parts The map of ids to extra nodes + * @returns {number} The initial id used */ - public makeCollapse(node: MmlNode, id: number | null): number { + public makeCollapse( + node: MmlNode, + id: number | null, + parts: SemanticMap + ): number { let oldCount = null; if (id === null) { id = this.idCount; @@ -552,7 +558,7 @@ export class Collapse { nodes.push(child); } }); - this.makeActions(nodes); + this.makeActions(node, nodes, parts); if (oldCount !== null) { this.idCount = oldCount; } @@ -560,11 +566,17 @@ export class Collapse { } /** - * @param {MmlNode[]} nodes The list of nodes to replace by maction nodes + * @param {MmlNode} root The top of the MathML tree + * @param {MmlNode[]} nodes The list of nodes to replace by maction nodes + * @param {SemanticMap} parts The map of ids to extra nodes */ - public makeActions(nodes: MmlNode[]) { + public makeActions(root: MmlNode, nodes: MmlNode[], parts: SemanticMap) { for (const node of nodes) { - this.makeAction(node); + const extra = + parts + .get(node.attributes.get('data-semantic-id') as string) + ?.slice(1) ?? []; + this.makeAction(root, node, extra); } } @@ -576,18 +588,20 @@ export class Collapse { } /** - * @param {MmlNode} node The node to make collapsible by replacing with an maction + * @param {MmlNode} root The top of the MathML tree + * @param {MmlNode} node The node to make collapsible by replacing with an maction + * @param {string[]} extra The extra nodes (if any) that need to be included in linked mactions */ - public makeAction(node: MmlNode) { + public makeAction(root: MmlNode, node: MmlNode, extra: string[]) { if (node.isKind('math')) { node = this.addMrow(node); } const factory = this.complexity.factory; const marker = node.getProperty('collapse-marker') as string; const parent = node.parent; - const variant = { 'data-mjx-collapsed': true } as PropertyList; + const def = { 'data-mjx-collapsed': true } as PropertyList; if (node.getProperty('collapse-variant')) { - variant.mathvariant = '-tex-variant'; + def.mathvariant = '-tex-variant'; } const maction = factory.create( 'maction', @@ -601,7 +615,7 @@ export class Collapse { ), }, [ - factory.create('mtext', variant, [ + factory.create('mtext', def, [ (factory.create('text') as TextNode).setText(marker), ]), ] @@ -615,6 +629,43 @@ export class Collapse { node.removeProperty('collapse-complexity'); parent.replaceChild(maction, node); maction.appendChild(node); + this.makeActionGroup(root, maction, extra); + } + + /** + * @param {MmlNode} root The root of the MathML tree + * @param {MmlNode} action The maction node that controls the potential group + * @param {string[]} extra The list of extra nodes for this group (if non-empty) + */ + public makeActionGroup(root: MmlNode, action: MmlNode, extra: string[]) { + if (!extra.length) return; + action.attributes.set('data-collapse-group', true); + const nodes: MmlNode[] = []; + root.walkTree((node) => { + if (extra.includes(node.attributes.get('data-semantic-id') as string)) { + nodes.push(node); + } + }); + const factory = this.complexity.factory; + for (const node of nodes) { + const parent = node.parent; + const maction = factory.create( + 'maction', + { + actiontype: 'toggle', + selection: 2, + 'data-collapsible': true, + 'data-collapse-id': action.attributes.get('id'), + 'data-semantic-complexity': node.attributes.get( + 'data-semantic-complexity' + ), + }, + [factory.create('mtext')] + ); + maction.inheritAttributesFrom(node); + parent.replaceChild(maction, node); + maction.appendChild(node); + } } /** @@ -622,7 +673,7 @@ export class Collapse { * in an maction (can't put one around the node). * * @param {MmlNode} node The math node to create an mrow for - * @returns {MmlNode} The newly created mrow + * @returns {MmlNode} The newly created mrow */ public addMrow(node: MmlNode): MmlNode { const mrow = this.complexity.factory.create( diff --git a/ts/a11y/complexity/visitor.ts b/ts/a11y/complexity/visitor.ts index 8a48712a8..db2be7695 100644 --- a/ts/a11y/complexity/visitor.ts +++ b/ts/a11y/complexity/visitor.ts @@ -38,6 +38,7 @@ import { MmlVisitor } from '../../core/MmlTree/MmlVisitor.js'; import { MmlFactory } from '../../core/MmlTree/MmlFactory.js'; import { Collapse } from './collapse.js'; import { OptionList, userOptions, defaultOptions } from '../../util/Options.js'; +import type { SemanticMap } from '../speech/StructureUtil.js'; /*==========================================================================*/ @@ -107,10 +108,10 @@ export class ComplexityVisitor extends MmlVisitor { /** * @override */ - public visitTree(node: MmlNode, id: number) { + public visitTree(node: MmlNode, id: number, parts: SemanticMap) { super.visitTree(node, true); if (this.options.makeCollapsible) { - id = this.collapse.makeCollapse(node, id); + id = this.collapse.makeCollapse(node, id, parts); } return id; } diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 2ee8bb73d..ba72b2c9c 100644 --- a/ts/a11y/explorer/Highlighter.ts +++ b/ts/a11y/explorer/Highlighter.ts @@ -94,6 +94,15 @@ export interface Highlighter { */ getMactionNodes(node: HTMLElement): HTMLElement[]; + /** + * Returns all the maction elements in a collapse group + * + * @param {HTMLElement} node The root node for the MathML tree + * @param {HTMLElement} maction The maction element whose group is to be found + * @returns {HTMLElement[]} The nodes in the collapse group + */ + getMactionGroup(node: HTMLElement, maction: HTMLElement): HTMLElement[]; + /** * Sets of the color the highlighter is using. * @@ -168,7 +177,11 @@ abstract class AbstractHighlighter implements Highlighter { public highlightAll(node: HTMLElement) { const mactions = this.getMactionNodes(node); for (const maction of mactions) { - this.highlight([maction]); + let parts: HTMLElement[] = maction.hasAttribute('data-collapse-group') + ? this.getMactionGroup(node, maction) + : [maction]; + parts = this.encloseNodes([...parts], node); + this.highlight(parts); } } @@ -282,17 +295,26 @@ abstract class AbstractHighlighter implements Highlighter { } /** - * Returns the maction sub nodes of a given node. - * - * @param {HTMLElement} node The root node. - * @returns {HTMLElement[]} The list of maction sub nodes. + * @override + */ + public abstract isMactionNode(node: Element): boolean; + + /** + * @override */ public abstract getMactionNodes(node: HTMLElement): HTMLElement[]; /** * @override */ - public abstract isMactionNode(node: Element): boolean; + public getMactionGroup( + node: HTMLElement, + maction: HTMLElement + ): HTMLElement[] { + return Array.from( + node.querySelectorAll(`#${maction.id},[data-collapse-id="${maction.id}"]`) + ); + } /** * Check if a node is already highlighted. @@ -452,7 +474,11 @@ class SvgHighlighter extends AbstractHighlighter { * @override */ public getMactionNodes(node: HTMLElement): HTMLElement[] { - return Array.from(node.querySelectorAll('[data-mml-node="maction"]')); + return Array.from( + node.querySelectorAll( + '[data-mml-node="maction"][data-collapsible]:not([data-collapse-id])' + ) + ); } } @@ -508,7 +534,11 @@ class ChtmlHighlighter extends AbstractHighlighter { * @override */ public getMactionNodes(node: HTMLElement): HTMLElement[] { - return Array.from(node.querySelectorAll('mjx-maction')); + return Array.from( + node.querySelectorAll( + 'mjx-maction[data-collapsible]:not([data-collapse-id])' + ) + ); } } diff --git a/ts/output/chtml/Wrappers/maction.ts b/ts/output/chtml/Wrappers/maction.ts index 68a167ea9..7c5ead16a 100644 --- a/ts/output/chtml/Wrappers/maction.ts +++ b/ts/output/chtml/Wrappers/maction.ts @@ -249,6 +249,15 @@ export const ChtmlMaction = (function (): ChtmlMactionClass { math.start.n = math.end.n = 0; } mml.nextToggleSelection(); + if (mml.attributes.get('data-collapse-group')) { + const id = mml.attributes.get('id'); + const selection = mml.attributes.get('selection'); + math.root.walkTree((node) => { + if (node.attributes.get('data-collapse-id') === id) { + node.attributes.set('selection', selection); + } + }); + } mathjax.handleRetriesFor(() => { math.rerender( document, diff --git a/ts/output/svg/Wrappers/maction.ts b/ts/output/svg/Wrappers/maction.ts index 696362c34..e8f03d7f4 100644 --- a/ts/output/svg/Wrappers/maction.ts +++ b/ts/output/svg/Wrappers/maction.ts @@ -247,6 +247,15 @@ export const SvgMaction = (function (): SvgMactionClass { math.start.n = math.end.n = 0; } mml.nextToggleSelection(); + if (mml.attributes.get('data-collapse-group')) { + const id = mml.attributes.get('id'); + const selection = mml.attributes.get('selection'); + math.root.walkTree((node) => { + if (node.attributes.get('data-collapse-id') === id) { + node.attributes.set('selection', selection); + } + }); + } mathjax.handleRetriesFor(() => { math.rerender( document, From 4f75838e306c9d6e2184bc96265dfec7b4f54fee Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 6 Aug 2026 06:45:36 -0400 Subject: [PATCH 10/13] Remove unneeded isHover property --- ts/a11y/explorer/MouseExplorer.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index 5bebda43e..cb5dfc721 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -122,11 +122,6 @@ export abstract class Hoverer extends AbstractMouseExplorer { */ protected nodeBBox: DOMRect; - /** - * used to tell if regino has splitNodes - */ - protected isHover = this.region instanceof HoverRegion; - /** * @class * @augments {AbstractMouseExplorer} @@ -135,11 +130,11 @@ export abstract class Hoverer extends AbstractMouseExplorer { * @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 {ExplorerMathItem} item The MathItem for this explorer * @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. - * @param {ExplorerMathItem} item The MathItem for this explorer */ protected constructor( public document: A11yDocument, @@ -200,8 +195,8 @@ export abstract class Hoverer extends AbstractMouseExplorer { protected display(node: HTMLElement, kind: T) { this.item.parseSemanticNodes(); let parts = this.item.getSplitNodes(node); - if (this.isHover) { - (this.region as HoverRegion).splitNodes = parts; + if (this.region instanceof HoverRegion) { + this.region.splitNodes = parts; } parts = this.highlighter.encloseNodes([...parts], this.node); this.highlighter.highlight(parts); From ea251fbc4a2357e8fd07e5f40b4819dc9e9ca3dd Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 6 Aug 2026 10:10:34 -0400 Subject: [PATCH 11/13] Fix tree walkers in two other places --- ts/core/MmlTree/MmlNode.ts | 16 ++++++++++++---- ts/core/Tree/Node.ts | 11 ++++++++--- ts/core/Tree/Wrapper.ts | 23 +++++++++++++++++------ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/ts/core/MmlTree/MmlNode.ts b/ts/core/MmlTree/MmlNode.ts index 8efe5204b..72fc2ce5d 100644 --- a/ts/core/MmlTree/MmlNode.ts +++ b/ts/core/MmlTree/MmlNode.ts @@ -29,6 +29,7 @@ import { AbstractNode, AbstractEmptyNode, NodeClass, + TreeWalkerState, } from '../Tree/Node.js'; import { MmlFactory } from './MmlFactory.js'; import { DOMAdaptor } from '../DOMAdaptor.js'; @@ -1091,11 +1092,18 @@ export abstract class AbstractMmlTokenNode extends AbstractMmlNode { * * @override */ - public walkTree(func: (node: MmlNode, data?: any) => void, data?: any) { - func(this, data); + public walkTree( + func: (node: MmlNode, data?: any) => boolean | void, + data?: any, + state: TreeWalkerState = { continue: true } + ) { + if (func(this, data)) { + state.continue = false; + return; + } for (const child of this.childNodes) { - if (child instanceof AbstractMmlNode) { - child.walkTree(func, data); + if (child instanceof AbstractMmlNode && state.continue) { + (child as AbstractMmlNode).walkTree(func, data, state); } } return data; diff --git a/ts/core/Tree/Node.ts b/ts/core/Tree/Node.ts index 8729d2da1..15ad25094 100644 --- a/ts/core/Tree/Node.ts +++ b/ts/core/Tree/Node.ts @@ -30,6 +30,11 @@ import { NodeFactory } from './NodeFactory.js'; export type Property = string | number | boolean; export type PropertyList = { [key: string]: Property }; +/** + * A state to tell if walking the tree should stop. + */ +export type TreeWalkerState = { continue: boolean }; + /*********************************************************/ /** * The generic Node interface @@ -336,7 +341,7 @@ export abstract class AbstractNode< public walkTree( func: (node: N, data?: any) => boolean | void, data?: any, - state: { continue: boolean } = { continue: true } + state: TreeWalkerState = { continue: true } ): any { if (func(this as any as N, data)) { state.continue = false; @@ -344,7 +349,7 @@ export abstract class AbstractNode< } for (const child of this.childNodes) { if (child && state.continue) { - (child as unknown as AbstractNode).walkTree(func, data, state); + (child as any as AbstractNode).walkTree(func, data, state); } } return data; @@ -406,7 +411,7 @@ export abstract class AbstractEmptyNode< * * @override */ - public walkTree(func: (node: N, data?: any) => void, data?: any) { + public walkTree(func: (node: N, data?: any) => boolean | void, data?: any) { func(this as any as N, data); return data; } diff --git a/ts/core/Tree/Wrapper.ts b/ts/core/Tree/Wrapper.ts index c797ecdf7..a7b1ccf51 100644 --- a/ts/core/Tree/Wrapper.ts +++ b/ts/core/Tree/Wrapper.ts @@ -21,7 +21,7 @@ * @author dpvc@mathjax.org (Davide Cervone) */ -import { Node, NodeClass } from './Node.js'; +import { Node, NodeClass, TreeWalkerState } from './Node.js'; import { WrapperFactory } from './WrapperFactory.js'; /*********************************************************/ @@ -67,7 +67,7 @@ export interface Wrapper< * @param {Function} func A function to apply to each wrapper in the tree rooted at this node * @param {any} data Data to pass to the function (as state information) */ - walkTree(func: (node: W, data?: any) => void, data?: any): void; + walkTree(func: (node: W, data?: any) => boolean | void, data?: any): void; } /*********************************************************/ @@ -156,12 +156,23 @@ export class AbstractWrapper< /** * @override */ - public walkTree(func: (node: W, data?: any) => void, data?: any) { - func(this as any as W, data); + public walkTree( + func: (node: W, data?: any) => boolean | void, + data?: any, + state: TreeWalkerState = { continue: true } + ) { + if (func(this as any as W, data)) { + state.continue = false; + return data; + } if ('childNodes' in this) { for (const child of this.childNodes) { - if (child) { - child.walkTree(func, data); + if (child && state.continue) { + (child as any as AbstractWrapper).walkTree( + func, + data, + state + ); } } } From 0de8988224a26c0f63d54fbfbcbe64951097f070 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 6 Aug 2026 16:05:05 -0400 Subject: [PATCH 12/13] Remove commented out variable that isn't needed --- ts/a11y/explorer/ExplorerPool.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ts/a11y/explorer/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts index 722d9681a..381bd5091 100644 --- a/ts/a11y/explorer/ExplorerPool.ts +++ b/ts/a11y/explorer/ExplorerPool.ts @@ -170,11 +170,6 @@ export class ExplorerPool { */ protected node: HTMLElement; - /** - * The corresponding Mathml node as a string. - */ - // protected mml: string; - /** * The primary highlighter shared by all explorers. */ From 49a1c278b0d46d6360da422efa6e3dcb890e45f6 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 7 Aug 2026 07:47:03 -0400 Subject: [PATCH 13/13] Add some missing comments, and undo prettier changes that should be in a different PR --- ts/a11y/explorer/MouseExplorer.ts | 7 +++++++ ts/a11y/explorer/Region.ts | 1 + ts/a11y/explorer/__locales__/de.json | 2 +- ts/a11y/explorer/__locales__/en.json | 2 +- ts/a11y/sre/require.mjs | 2 +- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index cb5dfc721..5f3e4559b 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -179,6 +179,13 @@ export abstract class Hoverer extends AbstractMouseExplorer { } } + /** + * Process a mousemove event to see if the node under the mouse has + * changed, and if so, unhighlight the old one and highlight the new + * one. + * + * @param {MouseEvent} event The move event + */ public MouseMove(event: MouseEvent) { const node = this.nodeAtXY(event, this.nodeQuery); if (node && node !== this.current) { diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index 30a3f07fe..1902de030 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -794,6 +794,7 @@ export class HoverRegion extends AbstractRegion { } this.inner.appendChild(mjx); this.position(node); + this.splitNodes = null; } /** diff --git a/ts/a11y/explorer/__locales__/de.json b/ts/a11y/explorer/__locales__/de.json index 4f2deb526..5223cd92c 100644 --- a/ts/a11y/explorer/__locales__/de.json +++ b/ts/a11y/explorer/__locales__/de.json @@ -5,7 +5,7 @@ "Help/Text2": "

Über das MathJax-Kontextmenü können Sie die Sprach- oder Braille-Ausgabe für mathematische Ausdrücke aktivieren oder deaktivieren, die Sprache für die Sprachausgabe festlegen sowie weitere Funktionen von MathJax einstellen. Insbesondere können Sie im Untermenü „Explorer“ festlegen, wie die mathematischen Ausdrücke auf der Seite gekennzeichnet werden sollen (z. B. durch die Ansage „Mathematik“, wenn der Ausdruck vorgelesen wird) und ob eine Meldung darüber angezeigt werden soll, dass der Buchstabe „h“ dieses Dialogfeld öffnet. Wenn Sie die Sprachausgabe und die Braille-Anzeige deaktivieren, werden der Ausdrucks-Explorer, dessen Highlighting und das Hilfe-Symbol deaktiviert.

\n\n

Die Unterstützung für taktile Braille-Geräte variiert je nach Screenreader, Browser und Betriebssystem. Wenn Sie ein Braille-Ausgabegerät verwenden, müssen Sie möglicherweise die Option „Mit Sprachausgabe kombinieren“ im Untermenü „Braille“ des Kontextmenüs auswählen, um anstelle der Sprachausgabe auf Ihrem Braille-Gerät eine Nemeth- oder Euro-Braille-Ausgabe zu erhalten. %1

\n\n

Das Kontextmenü bietet außerdem Optionen zum Anzeigen oder Kopieren einer MathML-Version des Ausdrucks oder seines ursprünglichen Quellformats, zum Erstellen einer SVG-Version des Ausdrucks sowie zum Anzeigen verschiedener weiterer Informationen.

\n\n

Wenn Sie schließlich im Optionenmenü den Eintrag „MathML versteckt einbinden“ auswählen, wird die Sprach- und Braille-Generierung von MathJax deaktiviert und stattdessen visuell unsichtbares MathML verwendet, das von einigen Screenreadern vorgelesen werden kann, wobei diese Funktion nicht von allen Screenreadern und Betriebssystemen unterstützt wird. Durch Auswahl der Sprach- oder Braille-Generierung in den entsprechenden Untermenüs wird das unsichtbare MathML wieder entfernt.

\n\n

Weitere Hilfe finden Sie in der MathJax-Dokumentation zur Barrierefreiheit.

", "Help/Title": "Hilfe zum MathJax Expression Explorer", "Mac/Select": "oder die VoiceOver-Pfeiltasten, um einen Ausdruck auszuwählen", - "Mac/Title": "macOS und iOS mit VoiceOver", + "Mac/Title": "macOS und iOS mit VoiceOver", "JumpTo": "Springe zu Zeile %1 und Spalte", "PositionMarked": "Position markiert", "Unix/Select": "Orca sollte automatisch in den Fokusmodus wechseln. Ist dies nicht der Fall, kannst du den Fokusmodus mit der Tastenkombination „Orca+a“ ein- oder ausschalten. Beachte außerdem, dass du mit „Orca+Pfeiltasten“ Ausdrücke auch im Durchsichtmodus untersuchen kannst.", diff --git a/ts/a11y/explorer/__locales__/en.json b/ts/a11y/explorer/__locales__/en.json index 9e13190ba..39ca9e662 100644 --- a/ts/a11y/explorer/__locales__/en.json +++ b/ts/a11y/explorer/__locales__/en.json @@ -5,7 +5,7 @@ "Help/Text2": "

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. Turning off speech and Braille will disable the expression explorer, its highlighting, and its help icon.

\n\n

Support for tactile Braille devices varies across screen readers, browsers, and operative systems. If you are using a Braille output device, you may need to select the \"Combine with Speech\" option in the contextual menu's Braille submenu in order to obtain Nemeth or Euro Braille output rather than the speech text on your Braille device. %1

\n\n

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.

\n\n

Finally, selecting the \"Insert Hidden MathML\" item from the options submenu will turn of MathJax's speech and Braille generation and instead use visually hidden MathML that some screen readers can voice, though support for this is not universal across all screen readers and operating systems. Selecting speech or Braille generation in their submenus will remove the hidden MathML again.

\n\n

For more help, see the MathJax accessibility documentation.

", "Help/Title": "MathJax Expression Explorer Help", "Mac/Select": "or the VoiceOver arrow keys to select an expression", - "Mac/Title": "MacOS and iOS using VoiceOver", + "Mac/Title": "MacOS and iOS using VoiceOver", "JumpTo": "Jump to row %1 and column", "PositionMarked": "Position marked", "Unix/Select": "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", diff --git a/ts/a11y/sre/require.mjs b/ts/a11y/sre/require.mjs index f335a1e3b..a45271169 100644 --- a/ts/a11y/sre/require.mjs +++ b/ts/a11y/sre/require.mjs @@ -1,2 +1,2 @@ -import { createRequire } from 'module'; +import {createRequire} from 'module'; global.require = createRequire(import.meta.url);