Skip to content

Commit 729afb9

Browse files
committed
Implement a method of enclosing multiple elements when there are extra ones selected.
1 parent 1c2bbef commit 729afb9

File tree

5 files changed

+245
-38
lines changed

5 files changed

+245
-38
lines changed

ts/a11y/explorer.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,6 @@ export function ExplorerMathDocumentMixin<
375375
'mjx-speech:focus': {
376376
outline: 'none',
377377
},
378-
'mjx-container .mjx-selected': {
379-
outline: '2px solid black',
380-
},
381378
'mjx-container > mjx-help': {
382379
display: 'none',
383380
position: 'absolute',

ts/a11y/explorer/Highlighter.ts

Lines changed: 229 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ export interface Highlighter {
9999
*/
100100
unhighlightAll(): void;
101101

102+
/**
103+
* Encloses multiple nodes if they in the same line
104+
*
105+
* @param {HTMLElement[]} parts The elements to be selected
106+
* @param {HTMLElement} node The root node of the expression
107+
* @returns {HTMLElement[]} The elements that shoudl be highlighted
108+
*/
109+
encloseNodes(parts: HTMLElement[], node: HTMLElement): HTMLElement[];
110+
102111
/**
103112
* Predicate to check if a node is an maction node.
104113
*
@@ -148,7 +157,7 @@ abstract class AbstractHighlighter implements Highlighter {
148157
/**
149158
* The Attribute for marking highlighted nodes.
150159
*/
151-
protected ATTR = 'sre-highlight-' + this.counter.toString();
160+
protected ATTR = 'data-sre-highlight-' + this.counter.toString();
152161

153162
/**
154163
* The foreground color.
@@ -165,6 +174,16 @@ abstract class AbstractHighlighter implements Highlighter {
165174
*/
166175
protected mactionName = '';
167176

177+
/**
178+
* The CSS selector to use to find the line-box container.
179+
*/
180+
protected static lineSelector = '';
181+
182+
/**
183+
* The attribute name for the line number.
184+
*/
185+
protected static lineAttr = '';
186+
168187
/**
169188
* List of currently highlighted nodes and their original background color.
170189
*/
@@ -233,6 +252,71 @@ abstract class AbstractHighlighter implements Highlighter {
233252
}
234253
}
235254

255+
/**
256+
* Create a container of a given size and position.
257+
*
258+
* @param {number} x The x-coordinate for the container
259+
* @param {number} y The y-coordinate for the container
260+
* @param {number} w The width for the container
261+
* @param {number} h The height for the container
262+
* @param {HTMLElement} node The mjx-container element
263+
* @param {HTMLElement} part The first node in the line to be enclosed
264+
* @returns {HTMLElement} The element of the given size
265+
*/
266+
protected abstract createEnclosure(
267+
x: number,
268+
y: number,
269+
w: number,
270+
h: number,
271+
node: HTMLElement,
272+
part: HTMLElement
273+
): HTMLElement;
274+
275+
/**
276+
* @override
277+
*/
278+
public encloseNodes(parts: HTMLElement[], node: HTMLElement): HTMLElement[] {
279+
if (parts.length === 1) {
280+
return parts;
281+
}
282+
const CLASS = this.constructor as typeof AbstractHighlighter;
283+
const selector = CLASS.lineSelector;
284+
const lineno = CLASS.lineAttr;
285+
const lines: Map<string, HTMLElement[]> = new Map();
286+
for (const part of parts) {
287+
const line = part.closest(selector);
288+
const n = line ? line.getAttribute(lineno) : '';
289+
if (!lines.has(n)) {
290+
lines.set(n, []);
291+
}
292+
lines.get(n).push(part);
293+
}
294+
for (const list of lines.values()) {
295+
if (list.length > 1) {
296+
let [L, T, R, B] = [Infinity, Infinity, -Infinity, -Infinity];
297+
for (const part of list) {
298+
part.setAttribute('data-mjx-enclosed', 'true');
299+
const { left, top, right, bottom } = part.getBoundingClientRect();
300+
if (top === bottom && left === right) continue;
301+
if (left < L) L = left;
302+
if (top < T) T = top;
303+
if (bottom > B) B = bottom;
304+
if (right > R) R = right;
305+
}
306+
const enclosure = this.createEnclosure(
307+
L,
308+
B,
309+
R - L,
310+
B - T,
311+
node,
312+
list[0]
313+
);
314+
parts.push(enclosure);
315+
}
316+
}
317+
return parts;
318+
}
319+
236320
/**
237321
* @override
238322
*/
@@ -305,6 +389,9 @@ abstract class AbstractHighlighter implements Highlighter {
305389
}
306390

307391
class SvgHighlighter extends AbstractHighlighter {
392+
protected static lineSelector = '[data-mjx-linebox]';
393+
protected static lineAttr = 'data-mjx-lineno';
394+
308395
/**
309396
* @override
310397
*/
@@ -332,31 +419,32 @@ class SvgHighlighter extends AbstractHighlighter {
332419
background: node.style.backgroundColor,
333420
foreground: node.style.color,
334421
};
335-
node.style.backgroundColor = this.background;
422+
if (!node.hasAttribute('data-mjx-enclosed')) {
423+
node.style.backgroundColor = this.background;
424+
}
336425
node.style.color = this.foreground;
337426
return info;
338427
}
339-
// This is a hack for v4.
340-
// TODO: v4 Change
341-
// const rect = (document ?? DomUtil).createElementNS(
342-
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
343-
rect.setAttribute(
344-
'sre-highlighter-added', // Mark highlighting rect.
345-
'true'
346-
);
347-
const padding = 40;
348-
const bbox: SVGRect = (node as any as SVGGraphicsElement).getBBox();
349-
rect.setAttribute('x', (bbox.x - padding).toString());
350-
rect.setAttribute('y', (bbox.y - padding).toString());
351-
rect.setAttribute('width', (bbox.width + 2 * padding).toString());
352-
rect.setAttribute('height', (bbox.height + 2 * padding).toString());
353-
const transform = node.getAttribute('transform');
354-
if (transform) {
355-
rect.setAttribute('transform', transform);
428+
if (node.hasAttribute('data-sre-highlighter-bbox')) {
429+
node.setAttribute(this.ATTR, 'true');
430+
node.setAttribute('fill', this.background);
431+
return { node: node, foreground: 'none' };
432+
}
433+
if (!node.hasAttribute('data-mjx-enclosed')) {
434+
const { x, y, width, height } = (
435+
node as any as SVGGraphicsElement
436+
).getBBox();
437+
const rect = this.createRect(
438+
x,
439+
y,
440+
width,
441+
height,
442+
node.getAttribute('transform')
443+
);
444+
rect.setAttribute('fill', this.background);
445+
node.parentNode.insertBefore(rect, node);
356446
}
357-
rect.setAttribute('fill', this.background);
358447
node.setAttribute(this.ATTR, 'true');
359-
node.parentNode.insertBefore(rect, node);
360448
info = { node: node, foreground: node.getAttribute('fill') };
361449
if (node.nodeName !== 'rect') {
362450
// We currently do not change foreground of collapsed nodes.
@@ -378,16 +466,95 @@ class SvgHighlighter extends AbstractHighlighter {
378466
* @override
379467
*/
380468
public unhighlightNode(info: Highlight) {
381-
const previous = info.node.previousSibling as HTMLElement;
382-
if (previous && previous.hasAttribute('sre-highlighter-added')) {
469+
const node = info.node;
470+
const previous = node.previousSibling as HTMLElement;
471+
if (node.hasAttribute('data-sre-highlighter-bbox')) {
472+
node.remove();
473+
}
474+
node.removeAttribute('data-mjx-enclosed');
475+
if (previous && previous.hasAttribute('data-sre-highlighter-added')) {
383476
info.foreground
384-
? info.node.setAttribute('fill', info.foreground)
385-
: info.node.removeAttribute('fill');
386-
info.node.parentNode.removeChild(previous);
477+
? node.setAttribute('fill', info.foreground)
478+
: node.removeAttribute('fill');
479+
previous.remove();
387480
return;
388481
}
389-
info.node.style.backgroundColor = info.background;
390-
info.node.style.color = info.foreground;
482+
node.style.backgroundColor = info.background;
483+
node.style.color = info.foreground;
484+
}
485+
486+
/**
487+
* @override
488+
*/
489+
protected createEnclosure(
490+
x: number,
491+
y: number,
492+
w: number,
493+
h: number,
494+
_node: HTMLElement,
495+
part: HTMLElement
496+
): HTMLElement {
497+
const [x1, y1] = this.screen2svg(x, y, part);
498+
const [x2, y2] = this.screen2svg(x + w, y - h, part);
499+
const rect = this.createRect(
500+
x1,
501+
y1,
502+
x2 - x1,
503+
y2 - y1,
504+
part.getAttribute('transform')
505+
);
506+
rect.setAttribute('data-sre-highlighter-bbox', 'true');
507+
part.parentNode.insertBefore(rect, part);
508+
return rect;
509+
}
510+
511+
/**
512+
* Convert screen coordinates in px to local SVG coordinates.
513+
*
514+
* @param {number} x The screen x coordinate
515+
* @param {number} y The screen y coordinate
516+
* @param {HTMLElement} part The element whose coordinate system is to be used
517+
* @returns {number[]} The x,y coordinates in the coordinates of part
518+
*/
519+
protected screen2svg(x: number, y: number, part: HTMLElement): number[] {
520+
const node = part as any as SVGGraphicsElement;
521+
const P = DOMPoint.fromPoint({ x, y }).matrixTransform(
522+
node.getScreenCTM().inverse()
523+
);
524+
return [P.x, P.y];
525+
}
526+
527+
/**
528+
* Create a rectangle of the given size and position.
529+
*
530+
* @param {number} x The x position of the rectangle
531+
* @param {number} y The y position of the rectangle
532+
* @param {number} w The width of the rectangle
533+
* @param {number} h The height of the rectangle
534+
* @param {string} transform The transform to apply, if any
535+
* @returns {HTMLElement} The generated rectangle element
536+
*/
537+
protected createRect(
538+
x: number,
539+
y: number,
540+
w: number,
541+
h: number,
542+
transform: string
543+
): HTMLElement {
544+
const padding = 40;
545+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
546+
rect.setAttribute(
547+
'data-sre-highlighter-added', // Mark highlighting rect.
548+
'true'
549+
);
550+
rect.setAttribute('x', String(x - padding));
551+
rect.setAttribute('y', String(y - padding));
552+
rect.setAttribute('width', String(w + 2 * padding));
553+
rect.setAttribute('height', String(h + 2 * padding));
554+
if (transform) {
555+
rect.setAttribute('transform', transform);
556+
}
557+
return rect as any as HTMLElement;
391558
}
392559

393560
/**
@@ -408,6 +575,9 @@ class SvgHighlighter extends AbstractHighlighter {
408575
}
409576

410577
class ChtmlHighlighter extends AbstractHighlighter {
578+
protected static lineSelector = 'mjx-linebox';
579+
protected static lineAttr = 'lineno';
580+
411581
/**
412582
* @override
413583
*/
@@ -426,7 +596,9 @@ class ChtmlHighlighter extends AbstractHighlighter {
426596
foreground: node.style.color,
427597
};
428598
if (!this.isHighlighted(node)) {
429-
node.style.backgroundColor = this.background;
599+
if (!node.hasAttribute('data-mjx-enclosed')) {
600+
node.style.backgroundColor = this.background;
601+
}
430602
node.style.color = this.foreground;
431603
}
432604
return info;
@@ -436,8 +608,34 @@ class ChtmlHighlighter extends AbstractHighlighter {
436608
* @override
437609
*/
438610
public unhighlightNode(info: Highlight) {
439-
info.node.style.backgroundColor = info.background;
440-
info.node.style.color = info.foreground;
611+
const node = info.node;
612+
node.style.backgroundColor = info.background;
613+
node.style.color = info.foreground;
614+
node.removeAttribute('data-mjx-enclosed');
615+
if (node.tagName.toLowerCase() === 'mjx-bbox') {
616+
node.remove();
617+
}
618+
}
619+
620+
/**
621+
* @override
622+
*/
623+
protected createEnclosure(
624+
x: number,
625+
y: number,
626+
w: number,
627+
h: number,
628+
node: HTMLElement
629+
): HTMLElement {
630+
const base = node.getBoundingClientRect();
631+
const enclosure = document.createElement('mjx-bbox');
632+
enclosure.style.width = w + 'px';
633+
enclosure.style.height = h + 'px';
634+
enclosure.style.left = x - base.left + 'px';
635+
enclosure.style.top = y - h - base.top + 'px';
636+
enclosure.style.position = 'absolute';
637+
node.prepend(enclosure);
638+
return enclosure;
441639
}
442640

443641
/**

ts/a11y/explorer/KeyExplorer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,10 +1001,12 @@ export class SpeechExplorer
10011001
// (i.e., we are focusing out)
10021002
//
10031003
if (this.current) {
1004-
for (const part of this.getSplitNodes(this.current)) {
1004+
this.pool.unhighlight();
1005+
for (const part of Array.from(
1006+
this.node.querySelectorAll('.mjx-selected')
1007+
)) {
10051008
part.classList.remove('mjx-selected');
10061009
}
1007-
this.pool.unhighlight();
10081010
if (this.document.options.a11y.tabSelects === 'last') {
10091011
this.refocus = this.current;
10101012
}
@@ -1022,8 +1024,11 @@ export class SpeechExplorer
10221024
this.currentMark = -1;
10231025
if (this.current) {
10241026
const parts = this.getSplitNodes(this.current);
1027+
this.highlighter.encloseNodes(parts, this.node);
10251028
for (const part of parts) {
1026-
part.classList.add('mjx-selected');
1029+
if (!part.getAttribute('data-mjx-enclosed')) {
1030+
part.classList.add('mjx-selected');
1031+
}
10271032
}
10281033
this.pool.highlight(parts);
10291034
this.addSpeech(node, addDescription);

ts/output/chtml.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ export class CHTML<N, T, D> extends CommonOutputJax<
151151

152152
'mjx-container [inline-breaks]': { display: 'inline' },
153153

154+
'mjx-container .mjx-selected': {
155+
outline: '2px solid black',
156+
},
157+
154158
//
155159
// These don't have Wrapper subclasses, so add their styles here
156160
//

ts/output/svg.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export class SVG<N, T, D> extends CommonOutputJax<
110110
fill: 'blue',
111111
stroke: 'blue',
112112
},
113-
'rect[sre-highlighter-added]:has(+ .mjx-selected)': {
113+
[[
114+
'rect[data-sre-highlighter-added]:has(+ .mjx-selected)',
115+
'rect[data-sre-highlighter-bbox].mjx-selected',
116+
].join(', ')]: {
114117
stroke: 'black',
115118
'stroke-width': '80px',
116119
},

0 commit comments

Comments
 (0)