From 729afb92ce3956c049b43a65fe49597fb3dcf6a5 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 16 Sep 2025 14:52:51 -0400 Subject: [PATCH 1/4] Implement a method of enclosing multiple elements when there are extra ones selected. --- ts/a11y/explorer.ts | 3 - ts/a11y/explorer/Highlighter.ts | 260 ++++++++++++++++++++++++++++---- ts/a11y/explorer/KeyExplorer.ts | 11 +- ts/output/chtml.ts | 4 + ts/output/svg.ts | 5 +- 5 files changed, 245 insertions(+), 38 deletions(-) diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 7964807c0..8dabc179e 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -375,9 +375,6 @@ export function ExplorerMathDocumentMixin< 'mjx-speech:focus': { outline: 'none', }, - 'mjx-container .mjx-selected': { - outline: '2px solid black', - }, 'mjx-container > mjx-help': { display: 'none', position: 'absolute', diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 7f520914c..35ac03c03 100644 --- a/ts/a11y/explorer/Highlighter.ts +++ b/ts/a11y/explorer/Highlighter.ts @@ -99,6 +99,15 @@ export interface Highlighter { */ unhighlightAll(): void; + /** + * Encloses multiple nodes if they in the same line + * + * @param {HTMLElement[]} parts The elements to be selected + * @param {HTMLElement} node The root node of the expression + * @returns {HTMLElement[]} The elements that shoudl be highlighted + */ + encloseNodes(parts: HTMLElement[], node: HTMLElement): HTMLElement[]; + /** * Predicate to check if a node is an maction node. * @@ -148,7 +157,7 @@ abstract class AbstractHighlighter implements Highlighter { /** * The Attribute for marking highlighted nodes. */ - protected ATTR = 'sre-highlight-' + this.counter.toString(); + protected ATTR = 'data-sre-highlight-' + this.counter.toString(); /** * The foreground color. @@ -165,6 +174,16 @@ abstract class AbstractHighlighter implements Highlighter { */ protected mactionName = ''; + /** + * The CSS selector to use to find the line-box container. + */ + protected static lineSelector = ''; + + /** + * The attribute name for the line number. + */ + protected static lineAttr = ''; + /** * List of currently highlighted nodes and their original background color. */ @@ -233,6 +252,71 @@ abstract class AbstractHighlighter implements Highlighter { } } + /** + * Create a container of a given size and position. + * + * @param {number} x The x-coordinate for the container + * @param {number} y The y-coordinate for the container + * @param {number} w The width for the container + * @param {number} h The height for the container + * @param {HTMLElement} node The mjx-container element + * @param {HTMLElement} part The first node in the line to be enclosed + * @returns {HTMLElement} The element of the given size + */ + protected abstract createEnclosure( + x: number, + y: number, + w: number, + h: number, + node: HTMLElement, + part: HTMLElement + ): HTMLElement; + + /** + * @override + */ + public encloseNodes(parts: HTMLElement[], node: HTMLElement): HTMLElement[] { + if (parts.length === 1) { + return parts; + } + const CLASS = this.constructor as typeof AbstractHighlighter; + const selector = CLASS.lineSelector; + const lineno = CLASS.lineAttr; + const lines: Map = new Map(); + for (const part of parts) { + const line = part.closest(selector); + const n = line ? line.getAttribute(lineno) : ''; + if (!lines.has(n)) { + lines.set(n, []); + } + lines.get(n).push(part); + } + for (const list of lines.values()) { + if (list.length > 1) { + let [L, T, R, B] = [Infinity, Infinity, -Infinity, -Infinity]; + for (const part of list) { + part.setAttribute('data-mjx-enclosed', 'true'); + const { left, top, right, bottom } = part.getBoundingClientRect(); + if (top === bottom && left === right) continue; + if (left < L) L = left; + if (top < T) T = top; + if (bottom > B) B = bottom; + if (right > R) R = right; + } + const enclosure = this.createEnclosure( + L, + B, + R - L, + B - T, + node, + list[0] + ); + parts.push(enclosure); + } + } + return parts; + } + /** * @override */ @@ -305,6 +389,9 @@ abstract class AbstractHighlighter implements Highlighter { } class SvgHighlighter extends AbstractHighlighter { + protected static lineSelector = '[data-mjx-linebox]'; + protected static lineAttr = 'data-mjx-lineno'; + /** * @override */ @@ -332,31 +419,32 @@ class SvgHighlighter extends AbstractHighlighter { background: node.style.backgroundColor, foreground: node.style.color, }; - node.style.backgroundColor = this.background; + if (!node.hasAttribute('data-mjx-enclosed')) { + node.style.backgroundColor = this.background; + } node.style.color = this.foreground; return info; } - // This is a hack for v4. - // TODO: v4 Change - // const rect = (document ?? DomUtil).createElementNS( - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - rect.setAttribute( - 'sre-highlighter-added', // Mark highlighting rect. - 'true' - ); - const padding = 40; - const bbox: SVGRect = (node as any as SVGGraphicsElement).getBBox(); - rect.setAttribute('x', (bbox.x - padding).toString()); - rect.setAttribute('y', (bbox.y - padding).toString()); - rect.setAttribute('width', (bbox.width + 2 * padding).toString()); - rect.setAttribute('height', (bbox.height + 2 * padding).toString()); - const transform = node.getAttribute('transform'); - if (transform) { - rect.setAttribute('transform', transform); + if (node.hasAttribute('data-sre-highlighter-bbox')) { + node.setAttribute(this.ATTR, 'true'); + node.setAttribute('fill', this.background); + return { node: node, foreground: 'none' }; + } + if (!node.hasAttribute('data-mjx-enclosed')) { + const { x, y, width, height } = ( + node as any as SVGGraphicsElement + ).getBBox(); + const rect = this.createRect( + x, + y, + width, + height, + node.getAttribute('transform') + ); + rect.setAttribute('fill', this.background); + node.parentNode.insertBefore(rect, node); } - rect.setAttribute('fill', this.background); node.setAttribute(this.ATTR, 'true'); - node.parentNode.insertBefore(rect, node); info = { node: node, foreground: node.getAttribute('fill') }; if (node.nodeName !== 'rect') { // We currently do not change foreground of collapsed nodes. @@ -378,16 +466,95 @@ class SvgHighlighter extends AbstractHighlighter { * @override */ public unhighlightNode(info: Highlight) { - const previous = info.node.previousSibling as HTMLElement; - if (previous && previous.hasAttribute('sre-highlighter-added')) { + const node = info.node; + const previous = node.previousSibling as HTMLElement; + if (node.hasAttribute('data-sre-highlighter-bbox')) { + node.remove(); + } + node.removeAttribute('data-mjx-enclosed'); + if (previous && previous.hasAttribute('data-sre-highlighter-added')) { info.foreground - ? info.node.setAttribute('fill', info.foreground) - : info.node.removeAttribute('fill'); - info.node.parentNode.removeChild(previous); + ? node.setAttribute('fill', info.foreground) + : node.removeAttribute('fill'); + previous.remove(); return; } - info.node.style.backgroundColor = info.background; - info.node.style.color = info.foreground; + node.style.backgroundColor = info.background; + node.style.color = info.foreground; + } + + /** + * @override + */ + protected createEnclosure( + x: number, + y: number, + w: number, + h: number, + _node: HTMLElement, + part: HTMLElement + ): HTMLElement { + const [x1, y1] = this.screen2svg(x, y, part); + const [x2, y2] = this.screen2svg(x + w, y - h, part); + const rect = this.createRect( + x1, + y1, + x2 - x1, + y2 - y1, + part.getAttribute('transform') + ); + rect.setAttribute('data-sre-highlighter-bbox', 'true'); + part.parentNode.insertBefore(rect, part); + return rect; + } + + /** + * Convert screen coordinates in px to local SVG coordinates. + * + * @param {number} x The screen x coordinate + * @param {number} y The screen y coordinate + * @param {HTMLElement} part The element whose coordinate system is to be used + * @returns {number[]} The x,y coordinates in the coordinates of part + */ + protected screen2svg(x: number, y: number, part: HTMLElement): number[] { + const node = part as any as SVGGraphicsElement; + const P = DOMPoint.fromPoint({ x, y }).matrixTransform( + node.getScreenCTM().inverse() + ); + return [P.x, P.y]; + } + + /** + * Create a rectangle of the given size and position. + * + * @param {number} x The x position of the rectangle + * @param {number} y The y position of the rectangle + * @param {number} w The width of the rectangle + * @param {number} h The height of the rectangle + * @param {string} transform The transform to apply, if any + * @returns {HTMLElement} The generated rectangle element + */ + protected createRect( + x: number, + y: number, + w: number, + h: number, + transform: string + ): HTMLElement { + const padding = 40; + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute( + 'data-sre-highlighter-added', // Mark highlighting rect. + 'true' + ); + rect.setAttribute('x', String(x - padding)); + rect.setAttribute('y', String(y - padding)); + rect.setAttribute('width', String(w + 2 * padding)); + rect.setAttribute('height', String(h + 2 * padding)); + if (transform) { + rect.setAttribute('transform', transform); + } + return rect as any as HTMLElement; } /** @@ -408,6 +575,9 @@ class SvgHighlighter extends AbstractHighlighter { } class ChtmlHighlighter extends AbstractHighlighter { + protected static lineSelector = 'mjx-linebox'; + protected static lineAttr = 'lineno'; + /** * @override */ @@ -426,7 +596,9 @@ class ChtmlHighlighter extends AbstractHighlighter { foreground: node.style.color, }; if (!this.isHighlighted(node)) { - node.style.backgroundColor = this.background; + if (!node.hasAttribute('data-mjx-enclosed')) { + node.style.backgroundColor = this.background; + } node.style.color = this.foreground; } return info; @@ -436,8 +608,34 @@ class ChtmlHighlighter extends AbstractHighlighter { * @override */ public unhighlightNode(info: Highlight) { - info.node.style.backgroundColor = info.background; - info.node.style.color = info.foreground; + const node = info.node; + node.style.backgroundColor = info.background; + node.style.color = info.foreground; + node.removeAttribute('data-mjx-enclosed'); + if (node.tagName.toLowerCase() === 'mjx-bbox') { + node.remove(); + } + } + + /** + * @override + */ + protected createEnclosure( + x: number, + y: number, + w: number, + h: number, + node: HTMLElement + ): HTMLElement { + const base = node.getBoundingClientRect(); + const enclosure = document.createElement('mjx-bbox'); + enclosure.style.width = w + 'px'; + enclosure.style.height = h + 'px'; + enclosure.style.left = x - base.left + 'px'; + enclosure.style.top = y - h - base.top + 'px'; + enclosure.style.position = 'absolute'; + node.prepend(enclosure); + return enclosure; } /** diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 7a4f54496..d7b110e73 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1001,10 +1001,12 @@ export class SpeechExplorer // (i.e., we are focusing out) // if (this.current) { - for (const part of this.getSplitNodes(this.current)) { + this.pool.unhighlight(); + for (const part of Array.from( + this.node.querySelectorAll('.mjx-selected') + )) { part.classList.remove('mjx-selected'); } - this.pool.unhighlight(); if (this.document.options.a11y.tabSelects === 'last') { this.refocus = this.current; } @@ -1022,8 +1024,11 @@ export class SpeechExplorer this.currentMark = -1; if (this.current) { const parts = this.getSplitNodes(this.current); + this.highlighter.encloseNodes(parts, this.node); for (const part of parts) { - part.classList.add('mjx-selected'); + if (!part.getAttribute('data-mjx-enclosed')) { + part.classList.add('mjx-selected'); + } } this.pool.highlight(parts); this.addSpeech(node, addDescription); diff --git a/ts/output/chtml.ts b/ts/output/chtml.ts index 68bf483c0..886b9d535 100644 --- a/ts/output/chtml.ts +++ b/ts/output/chtml.ts @@ -151,6 +151,10 @@ export class CHTML extends CommonOutputJax< 'mjx-container [inline-breaks]': { display: 'inline' }, + 'mjx-container .mjx-selected': { + outline: '2px solid black', + }, + // // These don't have Wrapper subclasses, so add their styles here // diff --git a/ts/output/svg.ts b/ts/output/svg.ts index 38844e48d..1e1113f0e 100644 --- a/ts/output/svg.ts +++ b/ts/output/svg.ts @@ -110,7 +110,10 @@ export class SVG extends CommonOutputJax< fill: 'blue', stroke: 'blue', }, - 'rect[sre-highlighter-added]:has(+ .mjx-selected)': { + [[ + 'rect[data-sre-highlighter-added]:has(+ .mjx-selected)', + 'rect[data-sre-highlighter-bbox].mjx-selected', + ].join(', ')]: { stroke: 'black', 'stroke-width': '80px', }, From d65325401a83091732d1bc8faab6313f3959cbf8 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 16 Sep 2025 19:17:16 -0400 Subject: [PATCH 2/4] Avoid semantic IDs that are not in the tree --- ts/a11y/explorer/KeyExplorer.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index d7b110e73..2b3847316 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1070,15 +1070,14 @@ export class SpeechExplorer 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')) - ); + ( + 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) => { - const node = this.node.querySelector(`[data-semantic-id="${child}"]`); - return node as HTMLElement; - }); + return [...rest] + .map((child) => this.getNode(child)) + .filter((node) => node !== null); } /** From 3370290af94b49566364c57b5a1d9de1abf8fb5b Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 16 Sep 2025 19:41:08 -0400 Subject: [PATCH 3/4] Return after removing the node, as there is no need to go futher in that case. --- ts/a11y/explorer/Highlighter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 35ac03c03..06c22e6cf 100644 --- a/ts/a11y/explorer/Highlighter.ts +++ b/ts/a11y/explorer/Highlighter.ts @@ -470,6 +470,7 @@ class SvgHighlighter extends AbstractHighlighter { const previous = node.previousSibling as HTMLElement; if (node.hasAttribute('data-sre-highlighter-bbox')) { node.remove(); + return; } node.removeAttribute('data-mjx-enclosed'); if (previous && previous.hasAttribute('data-sre-highlighter-added')) { From 0cea67e2f67606bc6969fe16471efdaca744d9c3 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 17 Oct 2025 13:37:59 -0400 Subject: [PATCH 4/4] Fix unhighlighting foreground colors in SVG, and copy the parts array so the cached array is not modified when the enclosure is added --- ts/a11y/explorer/Highlighter.ts | 25 ++++++++++++++++--------- ts/a11y/explorer/KeyExplorer.ts | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 06c22e6cf..24af02e2e 100644 --- a/ts/a11y/explorer/Highlighter.ts +++ b/ts/a11y/explorer/Highlighter.ts @@ -467,21 +467,28 @@ class SvgHighlighter extends AbstractHighlighter { */ public unhighlightNode(info: Highlight) { const node = info.node; - const previous = node.previousSibling as HTMLElement; if (node.hasAttribute('data-sre-highlighter-bbox')) { node.remove(); return; } - node.removeAttribute('data-mjx-enclosed'); - if (previous && previous.hasAttribute('data-sre-highlighter-added')) { - info.foreground - ? node.setAttribute('fill', info.foreground) - : node.removeAttribute('fill'); - previous.remove(); + if (node.tagName === 'svg' || node.tagName === 'MJX-CONTAINER') { + if (!node.hasAttribute('data-mjx-enclosed')) { + node.style.backgroundColor = info.background; + } + node.removeAttribute('data-mjx-enclosed'); + node.style.color = info.foreground; return; } - node.style.backgroundColor = info.background; - node.style.color = info.foreground; + const previous = node.previousSibling as HTMLElement; + if (previous?.hasAttribute('data-sre-highlighter-added')) { + previous.remove(); + } + node.removeAttribute('data-mjx-enclosed'); + if (info.foreground) { + node.setAttribute('fill', info.foreground); + } else { + node.removeAttribute('fill'); + } } /** diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 2b3847316..d028a7679 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1023,7 +1023,7 @@ export class SpeechExplorer this.current = node; this.currentMark = -1; if (this.current) { - const parts = this.getSplitNodes(this.current); + const parts = [...this.getSplitNodes(this.current)]; this.highlighter.encloseNodes(parts, this.node); for (const part of parts) { if (!part.getAttribute('data-mjx-enclosed')) {