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.ts b/ts/a11y/explorer.ts index ebfa30b68..3051521b6 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 + BaseMathItem: B ): 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,27 @@ 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; + } }; } @@ -379,7 +404,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', }, @@ -499,15 +524,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/Explorer.ts b/ts/a11y/explorer/Explorer.ts index 898460ffe..1fdf2e306 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'; @@ -276,4 +276,84 @@ 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) === '#' || + child.hasAttribute?.(ATTR.ADDED) + ) { + 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/ExplorerPool.ts b/ts/a11y/explorer/ExplorerPool.ts index 56fb1a3a7..381bd5091 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'; @@ -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,44 @@ 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[] - ) => - me.ContentHoverer.create( - doc, - pool, - doc.explorerRegions.magnifier, - node, - (x: HTMLElement) => x.hasAttribute('data-semantic-type'), - (x: HTMLElement) => x - ), - hover: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => me.FlameHoverer.create(doc, pool, null, node), - infoType: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => - me.ValueHoverer.create( + mouseMagnifier: (doc, pool, 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( doc, pool, doc.explorerRegions.tooltip1, node, - (x: HTMLElement) => x.hasAttribute('data-semantic-type'), - (x: HTMLElement) => x.getAttribute('data-semantic-type') + item, + 'data-semantic-type' ), - infoRole: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => - me.ValueHoverer.create( + infoRole: (doc, pool, node, item) => + ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip2, node, - (x: HTMLElement) => x.hasAttribute('data-semantic-role'), - (x: HTMLElement) => x.getAttribute('data-semantic-role') + item, + 'data-semantic-role' ), - infoPrefix: ( - doc: ExplorerMathDocument, - pool: ExplorerPool, - node: HTMLElement, - ..._rest: any[] - ) => - me.ValueHoverer.create( + infoPrefix: (doc, pool, node, item) => + ValueHoverer.create( doc, pool, doc.explorerRegions.tooltip3, node, - (x: HTMLElement) => x.hasAttribute?.('data-semantic-prefix-none'), - (x: HTMLElement) => x.getAttribute?.('data-semantic-prefix-none') + item, + '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, item) => + TreeColorer.create(doc, pool, null, node, item), }; /** @@ -217,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. */ @@ -254,17 +202,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 +217,6 @@ export class ExplorerPool { this.document, this, this.node, - this.mml, item ); } diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 82c1074e9..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. @@ -390,6 +412,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; } @@ -451,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])' + ) + ); } } @@ -490,6 +517,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; } @@ -505,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/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index eb34e58fe..7cd3c5124 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'; @@ -363,11 +364,6 @@ export class SpeechExplorer ['dblclick', this.DblClick.bind(this)], ]); - /** - * Semantic id to subtree map. - */ - private subtrees: Map> = null; - /** * @override */ @@ -449,10 +445,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 @@ -467,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; @@ -494,10 +491,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 @@ -508,6 +506,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 @@ -1081,10 +1089,10 @@ 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')) { + if (!part.getAttribute(ATTR.ENCLOSED)) { part.classList.add('mjx-selected'); } } @@ -1099,54 +1107,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). - * - * @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]; - } - // 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 = setdifference(sub, children); - return [...rest] - .map((child) => this.getNode(child)) - .filter((node) => node !== null); - } - /** * Remove the top-level speech node and create * a temporary one for the given node. @@ -1598,58 +1558,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 @@ -1736,7 +1644,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} @@ -1748,7 +1655,6 @@ export class SpeechExplorer protected node: HTMLElement, public brailleRegion: LiveRegion, public magnifyRegion: HoverRegion, - _mml: MmlNode, public item: ExplorerMathItem ) { super(document, pool, null, node); @@ -1773,10 +1679,6 @@ export class SpeechExplorer * @override */ public async Start() { - if (!this.subtrees) { - this.subtrees = new Map(); - this.getSubtrees(); - } // // If we aren't attached or already active, return // @@ -1806,6 +1708,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(); @@ -1821,6 +1724,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(); @@ -1859,6 +1763,7 @@ export class SpeechExplorer this.brailleRegion ); } + this.magnifyRegion.splitNodes = this.item.getSplitNodes(this.current); this.magnifyRegion.Update(this.current); } @@ -2020,97 +1925,4 @@ export class SpeechExplorer } return focus.join(' '); } - - /** - * Populates the subtrees map from the data-semantic-structure attribute. - */ - private getSubtrees() { - const node = this.node.querySelector('[data-semantic-structure]'); - if (!node) return; - 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 new Set([...a].filter((x) => !b.has(x))); } diff --git a/ts/a11y/explorer/MouseExplorer.ts b/ts/a11y/explorer/MouseExplorer.ts index 2babc8a96..5f3e4559b 100644 --- a/ts/a11y/explorer/MouseExplorer.ts +++ b/ts/a11y/explorer/MouseExplorer.ts @@ -21,9 +21,16 @@ * @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'; /** @@ -90,6 +97,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} @@ -98,6 +130,7 @@ 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 @@ -108,67 +141,76 @@ 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 ) { super(document, pool, region, node); + const top = + this.node.querySelector('[data-semantic-structure]') || this.node; + this.topBBox = top.getBoundingClientRect(); + this.nodeBBox = this.node.getBoundingClientRect(); } /** * @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; + } } /** * @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.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. + * 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 {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; + * @param {MouseEvent} event The move event + */ + 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 + */ + protected display(node: HTMLElement, kind: T) { + this.item.parseSemanticNodes(); + let parts = this.item.getSplitNodes(node); + if (this.region instanceof HoverRegion) { + this.region.splitNodes = parts; } - node = original; - while (node) { - if (this.nodeQuery(node)) { - return [node, this.nodeAccess(node)]; - } - const child = node.childNodes[0] as HTMLElement; - node = - child && child.tagName === 'defs' // This is for SVG. - ? (node.childNodes[1] as HTMLElement) - : child; + parts = this.highlighter.encloseNodes([...parts], this.node); + this.highlighter.highlight(parts); + if (typeof kind === 'string') { + this.region.Update(kind); } - return [null, null]; + this.region.Show(node); } } @@ -178,7 +220,29 @@ 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, + item: ExplorerMathItem, + attr: string + ) { + super( + document, + pool, + region, + node, + item, + (x) => x.hasAttribute?.(attr), + (x) => x.getAttribute?.(attr) + ); + } +} /** * Hoverer that displays node content (e.g., for magnification). @@ -186,7 +250,28 @@ 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, + region: HoverRegion, + node: HTMLElement, + item: ExplorerMathItem + ) { + super( + document, + pool, + region, + node, + item, + (x) => x.hasAttribute?.('data-semantic-id'), + (x) => x + ); + } +} /** * Highlights maction nodes on hovering. @@ -199,18 +284,33 @@ 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, + 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 4a4f757ee..1902de030 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 */ @@ -692,6 +697,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 +710,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 +729,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; @@ -773,10 +788,13 @@ 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); + this.splitNodes = null; } /** @@ -798,10 +816,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) as HTMLElement; + const enclosed = this.splitNodes; math.nodeName === 'svg' ? this.svgClone(node, enclosed, mjx, container) : this.chtmlClone(node, enclosed, mjx); @@ -819,17 +835,19 @@ export class HoverRegion extends AbstractRegion { * @param {Element[]} enclosed The elements to be cloned * @param {HTMLElement} mjx The container for the clones */ - protected chtmlClone( - node: HTMLElement, - enclosed: Element[], - mjx: HTMLElement - ) { + protected chtmlClone(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,38 +864,45 @@ 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'); + 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, y] = [X, Y + bbox.y]; + if (left === undefined || x < left) left = x; + if (right === undefined || x + bbox.width > right) { + right = x + bbox.width; } - const [cx, cy] = this.xy(child); - clone.setAttribute('transform', `translate(${cx}, ${cy})`); + 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); - 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; } @@ -891,7 +916,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 +927,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..119228121 100644 --- a/ts/a11y/explorer/TreeExplorer.ts +++ b/ts/a11y/explorer/TreeExplorer.ts @@ -34,8 +34,7 @@ export class AbstractTreeExplorer extends AbstractExplorer { public document: A11yDocument, public pool: ExplorerPool, public region: Region, - protected node: HTMLElement, - protected mml: HTMLElement + protected node: HTMLElement ) { super(document, pool, null, node); } 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 new file mode 100644 index 000000000..af9edcafa --- /dev/null +++ b/ts/a11y/speech/StructureUtil.ts @@ -0,0 +1,117 @@ +import { MmlNode } from '../../core/MmlTree/MmlNode.js'; + +/**********************************************************************/ +/* + * Some Aux functions for parsing the semantic structure sexpression + */ + +export type SexpTree = string | SexpTree[]; +export type ParentMap = Map; +export type SemanticMap = Map; + +export class StructureUtil { + /** + * Helper to tokenize input + * + * @param {string} str The semantic structure. + * @returns {string[]} The tokenized list. + */ + protected 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. + */ + protected 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]; + } + + /** + * Recursively map semantic ids to the nearest parent ids + * + * @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 + */ + 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); + } + if (node.isToken) return map; + for (const child of node.childNodes) { + this.mapParents(child, nid ?? id, map); + } + return map; + } + + /** + * Map the semantic ids to themselves and any nodes outside their MathML tree + * + * @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 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 (extra.length) { + map.set(id, [id, ...extra]); + } + return extra; + } +} 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 d9269b76d..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 @@ -124,8 +129,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 +338,18 @@ 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: TreeWalkerState = { 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 any as AbstractNode).walkTree(func, data, state); } } return data; @@ -398,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 + ); } } } diff --git a/ts/output/chtml/Wrappers/maction.ts b/ts/output/chtml/Wrappers/maction.ts index df8c341ec..7c5ead16a 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,23 @@ 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 - ); + 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, + mml.attributes.get('data-maction-id') + ? STATE.ENRICHED + : STATE.RERENDER + ); + }); event.stopPropagation(); }); }, diff --git a/ts/output/chtml/Wrappers/mtd.ts b/ts/output/chtml/Wrappers/mtd.ts index 98c42e7ba..1a230905e 100644 --- a/ts/output/chtml/Wrappers/mtd.ts +++ b/ts/output/chtml/Wrappers/mtd.ts @@ -148,6 +148,11 @@ 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', diff --git a/ts/output/svg/Wrappers/maction.ts b/ts/output/svg/Wrappers/maction.ts index 57666aab7..e8f03d7f4 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,23 @@ 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 - ); + 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, + mml.attributes.get('data-maction-id') + ? STATE.ENRICHED + : STATE.RERENDER + ); + }); event.stopPropagation(); }); },