From c417c4978122bfe8dbcbc50cec054cb0822cabe8 Mon Sep 17 00:00:00 2001 From: zorkow Date: Tue, 9 Sep 2025 16:55:12 +0200 Subject: [PATCH 1/5] ensures highlighting of all nodes that are spoken --- ts/a11y/explorer/KeyExplorer.ts | 125 +++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 943def94d..ef9c2db1b 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 subtee map. + */ + private subtrees: Map> = new Map(); + /** * @override */ @@ -1040,7 +1045,35 @@ export class SpeechExplorer if (!id) { return [node]; } - return Array.from(this.node.querySelectorAll(`[data-semantic-id="${id}"]`)); + const parts = Array.from( + this.node.querySelectorAll(`[data-semantic-id="${id}"]`) + ) as HTMLElement[]; + const subtree = this.subtree(id, parts); + return [...parts, ...subtree]; + } + + /** + * 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; + }); } /** @@ -1496,6 +1529,7 @@ export class SpeechExplorer public item: ExplorerMathItem ) { super(document, pool, null, node); + this.getSubtrees(); } /** @@ -1730,4 +1764,93 @@ export class SpeechExplorer } return focus.join(' '); } + + 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 +// +type SexpTree = string | SexpTree[]; + +// Helper to tokenize input +/** + * + * @param str + */ +function tokenize(str: string): string[] { + return str.replace(/\(/g, ' ( ').replace(/\)/g, ' ) ').trim().split(/\s+/); +} + +// Recursive parser to convert tokens into a tree +/** + * + * @param tokens + */ +function parse(tokens: string[]): SexpTree { + if (!tokens.length) return null; + + const token = tokens.shift(); + + if (token === '(') { + const node = []; + while (tokens[0] !== ')') { + node.push(parse(tokens)); + } + tokens.shift(); // remove ')' + return node; + } else { + return token; + } +} + +// Flatten tree and build the map +/** + * + * @param tree + * @param map + */ +function buildMap(tree: SexpTree, map = new Map()) { + if (typeof tree === 'string') { + if (!map.has(tree)) map.set(tree, new Set()); + return new Set(); + } + + const [root, ...children] = tree; + const rootId = root; + const descendants = new Set(); + + for (const child of children) { + const childRoot = typeof child === 'string' ? child : child[0]; + if (!map.has(rootId)) map.set(rootId, new Set()); + + const childDescendants = buildMap(child, map); + descendants.add(childRoot); + childDescendants.forEach((d) => descendants.add(d)); + } + + map.set(rootId, descendants); + return descendants; +} + +// Can be replaced with ES2024 +/** + * + * @param a + * @param 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))); } From 1c2bbef99dac29e62c855049eaab1f841db53aa3 Mon Sep 17 00:00:00 2001 From: zorkow Date: Wed, 10 Sep 2025 11:30:59 +0200 Subject: [PATCH 2/5] Add comments --- ts/a11y/explorer/KeyExplorer.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index ef9c2db1b..7a4f54496 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -422,7 +422,7 @@ export class SpeechExplorer ]); /** - * Semantic id to subtee map. + * Semantic id to subtree map. */ private subtrees: Map> = new Map(); @@ -1765,6 +1765,9 @@ 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; @@ -1775,23 +1778,23 @@ export class SpeechExplorer } } -// Some Aux functions +// Some Aux functions for parsing the semantic structure sexpression // type SexpTree = string | SexpTree[]; -// Helper to tokenize input /** + * Helper to tokenize input * - * @param str + * @param str The semantic structure. */ function tokenize(str: string): string[] { return str.replace(/\(/g, ' ( ').replace(/\)/g, ' ) ').trim().split(/\s+/); } -// Recursive parser to convert tokens into a tree /** + * Recursive parser to convert tokens into a tree * - * @param tokens + * @param tokens The tokens from the semantic structure. */ function parse(tokens: string[]): SexpTree { if (!tokens.length) return null; @@ -1810,11 +1813,11 @@ function parse(tokens: string[]): SexpTree { } } -// Flatten tree and build the map /** + * Flattens the tree and builds the map. * - * @param tree - * @param map + * @param tree The sexpression tree. + * @param map The map to populate. */ function buildMap(tree: SexpTree, map = new Map()) { if (typeof tree === 'string') { @@ -1839,11 +1842,12 @@ function buildMap(tree: SexpTree, map = new Map()) { return descendants; } -// Can be replaced with ES2024 +// Can be replaced with ES2024 implementation of Set.prototyp.difference /** + * Set difference between two sets A and B: A\B. * - * @param a - * @param b + * @param a Initial set. + * @param b Set to remove from A. */ function setdifference(a: Set, b: Set): Set { if (!a) { From 20db7c765843fd10ea62132b082fe49a09db670e Mon Sep 17 00:00:00 2001 From: zorkow Date: Fri, 12 Sep 2025 14:50:02 +0200 Subject: [PATCH 3/5] rewrite to lazy init and parsing --- ts/a11y/explorer/KeyExplorer.ts | 37 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 7a4f54496..625b0e046 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -424,7 +424,7 @@ export class SpeechExplorer /** * Semantic id to subtree map. */ - private subtrees: Map> = new Map(); + private subtrees: Map> = null; /** * @override @@ -1529,7 +1529,6 @@ export class SpeechExplorer public item: ExplorerMathItem ) { super(document, pool, null, node); - this.getSubtrees(); } /** @@ -1551,6 +1550,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 // @@ -1797,20 +1800,26 @@ function tokenize(str: string): string[] { * @param tokens The tokens from the semantic structure. */ function parse(tokens: string[]): SexpTree { - if (!tokens.length) return null; - - const token = tokens.shift(); - - if (token === '(') { - const node = []; - while (tokens[0] !== ')') { - node.push(parse(tokens)); + const stack: SexpTree[][] = [[]]; + + for (const token of tokens) { + console.log(stack.toString()); + if (token === '(') { + // Start a new nested list and push it onto the stack + const newNode: SexpTree = []; + stack[stack.length - 1].push(newNode); + stack.push(newNode); + } else if (token === ')') { + // Close the current list by popping from the stack + stack.pop(); + } else { + // Add a literal token to the current list + stack[stack.length - 1].push(token); } - tokens.shift(); // remove ')' - return node; - } else { - return token; } + + // The final result is the first (and only) element of the base list + return stack[0][0]; } /** From 93aef48aabcb1bb6e48bce8b031290336006b8c4 Mon Sep 17 00:00:00 2001 From: zorkow Date: Thu, 18 Sep 2025 12:01:47 +0800 Subject: [PATCH 4/5] introduce subtree caching --- ts/a11y/explorer/KeyExplorer.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 625b0e046..da646d9e8 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1034,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). * @@ -1045,11 +1047,16 @@ export class SpeechExplorer if (!id) { return [node]; } + // Here we need to cash 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); - return [...parts, ...subtree]; + this.cacheParts.set(id, [...parts, ...subtree]); + return this.cacheParts.get(id); } /** @@ -1781,6 +1788,8 @@ export class SpeechExplorer } } +/**********************************************************************/ + // Some Aux functions for parsing the semantic structure sexpression // type SexpTree = string | SexpTree[]; @@ -1803,7 +1812,6 @@ function parse(tokens: string[]): SexpTree { const stack: SexpTree[][] = [[]]; for (const token of tokens) { - console.log(stack.toString()); if (token === '(') { // Start a new nested list and push it onto the stack const newNode: SexpTree = []; From 0ab25f73081a75ecb6f7a8d8277b2aab3451c6e5 Mon Sep 17 00:00:00 2001 From: zorkow Date: Sun, 5 Oct 2025 15:53:16 +0530 Subject: [PATCH 5/5] finished review comments --- ts/a11y/explorer/KeyExplorer.ts | 41 +++++++++++++-------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index da646d9e8..be7868767 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1789,15 +1789,16 @@ export class SpeechExplorer } /**********************************************************************/ - -// Some Aux functions for parsing the semantic structure sexpression -// +/* + * Some Aux functions for parsing the semantic structure sexpression + */ type SexpTree = string | SexpTree[]; /** * Helper to tokenize input * - * @param str The semantic structure. + * @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+/); @@ -1806,55 +1807,45 @@ function tokenize(str: string): string[] { /** * Recursive parser to convert tokens into a tree * - * @param tokens The tokens from the semantic structure. + * @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 === '(') { - // Start a new nested list and push it onto the stack const newNode: SexpTree = []; stack[stack.length - 1].push(newNode); stack.push(newNode); } else if (token === ')') { - // Close the current list by popping from the stack stack.pop(); } else { - // Add a literal token to the current list stack[stack.length - 1].push(token); } } - - // The final result is the first (and only) element of the base list return stack[0][0]; } /** * Flattens the tree and builds the map. * - * @param tree The sexpression tree. - * @param map The map to populate. + * @param {SexpTree} tree The sexpression tree. + * @param {Map>} map The map to populate. */ -function buildMap(tree: SexpTree, map = new Map()) { +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; - const descendants = new Set(); - + const rootId = root as string; + const descendants: Set = new Set(); for (const child of children) { const childRoot = typeof child === 'string' ? child : child[0]; - if (!map.has(rootId)) map.set(rootId, new Set()); - const childDescendants = buildMap(child, map); - descendants.add(childRoot); - childDescendants.forEach((d) => descendants.add(d)); + descendants.add(childRoot as string); + childDescendants.forEach((d: string) => descendants.add(d)); } - map.set(rootId, descendants); return descendants; } @@ -1863,8 +1854,8 @@ function buildMap(tree: SexpTree, map = new Map()) { /** * Set difference between two sets A and B: A\B. * - * @param a Initial set. - * @param b Set to remove from A. + * @param {Set} a Initial set. + * @param {Set} b Set to remove from A. */ function setdifference(a: Set, b: Set): Set { if (!a) {