Skip to content

Commit 5d363f4

Browse files
authored
Merge pull request #1349 from mathjax/fix/split_highlighting
Ensures highlighting of all nodes that are spoken
2 parents e3a3acc + 9f7500e commit 5d363f4

File tree

1 file changed

+136
-1
lines changed

1 file changed

+136
-1
lines changed

ts/a11y/explorer/KeyExplorer.ts

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,11 @@ export class SpeechExplorer
421421
['dblclick', this.DblClick.bind(this)],
422422
]);
423423

424+
/**
425+
* Semantic id to subtree map.
426+
*/
427+
private subtrees: Map<string, Set<string>> = null;
428+
424429
/**
425430
* @override
426431
*/
@@ -1029,6 +1034,8 @@ export class SpeechExplorer
10291034
this.node.removeAttribute('aria-busy');
10301035
}
10311036

1037+
private cacheParts: Map<string, HTMLElement[]> = new Map();
1038+
10321039
/**
10331040
* Get all nodes with the same semantic id (multiple nodes if there are line breaks).
10341041
*
@@ -1040,7 +1047,40 @@ export class SpeechExplorer
10401047
if (!id) {
10411048
return [node];
10421049
}
1043-
return Array.from(this.node.querySelectorAll(`[data-semantic-id="${id}"]`));
1050+
// Here we need to cache the subtrees.
1051+
if (this.cacheParts.has(id)) {
1052+
return this.cacheParts.get(id);
1053+
}
1054+
const parts = Array.from(
1055+
this.node.querySelectorAll(`[data-semantic-id="${id}"]`)
1056+
) as HTMLElement[];
1057+
const subtree = this.subtree(id, parts);
1058+
this.cacheParts.set(id, [...parts, ...subtree]);
1059+
return this.cacheParts.get(id);
1060+
}
1061+
1062+
/**
1063+
* Retrieve the elements in the semantic subtree that are not in the DOM subtree.
1064+
*
1065+
* @param {string} id The semantic id of the root node.
1066+
* @param {HTMLElement[]} nodes The list of nodes corresponding to that id
1067+
* (could be multiple for linebroken ones).
1068+
* @returns {HTMLElement[]} The list of nodes external to the DOM trees rooted
1069+
* by any of the input nodes.
1070+
*/
1071+
private subtree(id: string, nodes: HTMLElement[]): HTMLElement[] {
1072+
const sub = this.subtrees.get(id);
1073+
const children: Set<string> = new Set();
1074+
for (const node of nodes) {
1075+
Array.from(node.querySelectorAll(`[data-semantic-id]`)).forEach((x) =>
1076+
children.add(x.getAttribute('data-semantic-id'))
1077+
);
1078+
}
1079+
const rest = setdifference(sub, children);
1080+
return [...rest].map((child) => {
1081+
const node = this.node.querySelector(`[data-semantic-id="${child}"]`);
1082+
return node as HTMLElement;
1083+
});
10441084
}
10451085

10461086
/**
@@ -1517,6 +1557,10 @@ export class SpeechExplorer
15171557
* @override
15181558
*/
15191559
public async Start() {
1560+
if (!this.subtrees) {
1561+
this.subtrees = new Map();
1562+
this.getSubtrees();
1563+
}
15201564
//
15211565
// If we aren't attached or already active, return
15221566
//
@@ -1730,4 +1774,95 @@ export class SpeechExplorer
17301774
}
17311775
return focus.join(' ');
17321776
}
1777+
1778+
/**
1779+
* Populates the subtrees map from the data-semantic-structure attribute.
1780+
*/
1781+
private getSubtrees() {
1782+
const node = this.node.querySelector('[data-semantic-structure]');
1783+
if (!node) return;
1784+
const sexp = node.getAttribute('data-semantic-structure');
1785+
const tokens = tokenize(sexp);
1786+
const tree = parse(tokens);
1787+
buildMap(tree, this.subtrees);
1788+
}
1789+
}
1790+
1791+
/**********************************************************************/
1792+
/*
1793+
* Some Aux functions for parsing the semantic structure sexpression
1794+
*/
1795+
type SexpTree = string | SexpTree[];
1796+
1797+
/**
1798+
* Helper to tokenize input
1799+
*
1800+
* @param {string} str The semantic structure.
1801+
* @returns {string[]} The tokenized list.
1802+
*/
1803+
function tokenize(str: string): string[] {
1804+
return str.replace(/\(/g, ' ( ').replace(/\)/g, ' ) ').trim().split(/\s+/);
1805+
}
1806+
1807+
/**
1808+
* Recursive parser to convert tokens into a tree
1809+
*
1810+
* @param {string} tokens The tokens from the semantic structure.
1811+
* @returns {SexpTree} Array list for the semantic structure sexpression.
1812+
*/
1813+
function parse(tokens: string[]): SexpTree {
1814+
const stack: SexpTree[][] = [[]];
1815+
for (const token of tokens) {
1816+
if (token === '(') {
1817+
const newNode: SexpTree = [];
1818+
stack[stack.length - 1].push(newNode);
1819+
stack.push(newNode);
1820+
} else if (token === ')') {
1821+
stack.pop();
1822+
} else {
1823+
stack[stack.length - 1].push(token);
1824+
}
1825+
}
1826+
return stack[0][0];
1827+
}
1828+
1829+
/**
1830+
* Flattens the tree and builds the map.
1831+
*
1832+
* @param {SexpTree} tree The sexpression tree.
1833+
* @param {Map<string, Set<string>>} map The map to populate.
1834+
*/
1835+
function buildMap(tree: SexpTree, map: Map<string, Set<string>>) {
1836+
if (typeof tree === 'string') {
1837+
if (!map.has(tree)) map.set(tree, new Set());
1838+
return new Set();
1839+
}
1840+
const [root, ...children] = tree;
1841+
const rootId = root as string;
1842+
const descendants: Set<string> = new Set();
1843+
for (const child of children) {
1844+
const childRoot = typeof child === 'string' ? child : child[0];
1845+
const childDescendants = buildMap(child, map);
1846+
descendants.add(childRoot as string);
1847+
childDescendants.forEach((d: string) => descendants.add(d));
1848+
}
1849+
map.set(rootId, descendants);
1850+
return descendants;
1851+
}
1852+
1853+
// Can be replaced with ES2024 implementation of Set.prototyp.difference
1854+
/**
1855+
* Set difference between two sets A and B: A\B.
1856+
*
1857+
* @param {Set<string>} a Initial set.
1858+
* @param {Set<string>} b Set to remove from A.
1859+
*/
1860+
function setdifference(a: Set<string>, b: Set<string>): Set<string> {
1861+
if (!a) {
1862+
return new Set();
1863+
}
1864+
if (!b) {
1865+
return a;
1866+
}
1867+
return new Set([...a].filter((x) => !b.has(x)));
17331868
}

0 commit comments

Comments
 (0)