Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 136 additions & 1 deletion ts/a11y/explorer/KeyExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,11 @@ export class SpeechExplorer
['dblclick', this.DblClick.bind(this)],
]);

/**
* Semantic id to subtree map.
*/
private subtrees: Map<string, Set<string>> = null;

/**
* @override
*/
Expand Down Expand Up @@ -1029,6 +1034,8 @@ export class SpeechExplorer
this.node.removeAttribute('aria-busy');
}

private cacheParts: Map<string, HTMLElement[]> = new Map();

/**
* Get all nodes with the same semantic id (multiple nodes if there are line breaks).
*
Expand All @@ -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 cash the subtrees.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"cache" not "cash"?

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<string> = 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;
});
}

/**
Expand Down Expand Up @@ -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
//
Expand Down Expand Up @@ -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<string, Set<string>>} map The map to populate.
*/
function buildMap(tree: SexpTree, map: Map<string, Set<string>>) {
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<string> = 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<string>} a Initial set.
* @param {Set<string>} b Set to remove from A.
*/
function setdifference(a: Set<string>, b: Set<string>): Set<string> {
if (!a) {
return new Set();
}
if (!b) {
return a;
}
return new Set([...a].filter((x) => !b.has(x)));
}