diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 943def94d..92e8ab7d4 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -421,6 +421,11 @@ export class SpeechExplorer ['dblclick', this.DblClick.bind(this)], ]); + /** + * Semantic id to subtree map. + */ + private subtrees: Map> = null; + /** * @override */ @@ -1029,6 +1034,8 @@ 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). * @@ -1040,7 +1047,40 @@ export class SpeechExplorer if (!id) { return [node]; } - return Array.from(this.node.querySelectorAll(`[data-semantic-id="${id}"]`)); + // 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]`)).forEach((x) => + children.add(x.getAttribute('data-semantic-id')) + ); + } + const rest = setdifference(sub, children); + return [...rest].map((child) => { + const node = this.node.querySelector(`[data-semantic-id="${child}"]`); + return node as HTMLElement; + }); } /** @@ -1517,6 +1557,10 @@ 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 // @@ -1730,4 +1774,95 @@ 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. + */ +function buildMap(tree: SexpTree, map: Map>) { + 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. + */ +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))); }