Skip to content

Commit 27c47f3

Browse files
authored
Merge pull request #1354 from mathjax/update/highlight-enclosures
Implement a method of enclosing multiple elements when there are extra ones selected.
2 parents 859405e + 0cea67e commit 27c47f3

File tree

5 files changed

+261
-47
lines changed

5 files changed

+261
-47
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: 'sticky',

ts/a11y/explorer/Highlighter.ts

Lines changed: 238 additions & 32 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,103 @@ 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')) {
383-
info.foreground
384-
? info.node.setAttribute('fill', info.foreground)
385-
: info.node.removeAttribute('fill');
386-
info.node.parentNode.removeChild(previous);
469+
const node = info.node;
470+
if (node.hasAttribute('data-sre-highlighter-bbox')) {
471+
node.remove();
472+
return;
473+
}
474+
if (node.tagName === 'svg' || node.tagName === 'MJX-CONTAINER') {
475+
if (!node.hasAttribute('data-mjx-enclosed')) {
476+
node.style.backgroundColor = info.background;
477+
}
478+
node.removeAttribute('data-mjx-enclosed');
479+
node.style.color = info.foreground;
387480
return;
388481
}
389-
info.node.style.backgroundColor = info.background;
390-
info.node.style.color = info.foreground;
482+
const previous = node.previousSibling as HTMLElement;
483+
if (previous?.hasAttribute('data-sre-highlighter-added')) {
484+
previous.remove();
485+
}
486+
node.removeAttribute('data-mjx-enclosed');
487+
if (info.foreground) {
488+
node.setAttribute('fill', info.foreground);
489+
} else {
490+
node.removeAttribute('fill');
491+
}
492+
}
493+
494+
/**
495+
* @override
496+
*/
497+
protected createEnclosure(
498+
x: number,
499+
y: number,
500+
w: number,
501+
h: number,
502+
_node: HTMLElement,
503+
part: HTMLElement
504+
): HTMLElement {
505+
const [x1, y1] = this.screen2svg(x, y, part);
506+
const [x2, y2] = this.screen2svg(x + w, y - h, part);
507+
const rect = this.createRect(
508+
x1,
509+
y1,
510+
x2 - x1,
511+
y2 - y1,
512+
part.getAttribute('transform')
513+
);
514+
rect.setAttribute('data-sre-highlighter-bbox', 'true');
515+
part.parentNode.insertBefore(rect, part);
516+
return rect;
517+
}
518+
519+
/**
520+
* Convert screen coordinates in px to local SVG coordinates.
521+
*
522+
* @param {number} x The screen x coordinate
523+
* @param {number} y The screen y coordinate
524+
* @param {HTMLElement} part The element whose coordinate system is to be used
525+
* @returns {number[]} The x,y coordinates in the coordinates of part
526+
*/
527+
protected screen2svg(x: number, y: number, part: HTMLElement): number[] {
528+
const node = part as any as SVGGraphicsElement;
529+
const P = DOMPoint.fromPoint({ x, y }).matrixTransform(
530+
node.getScreenCTM().inverse()
531+
);
532+
return [P.x, P.y];
533+
}
534+
535+
/**
536+
* Create a rectangle of the given size and position.
537+
*
538+
* @param {number} x The x position of the rectangle
539+
* @param {number} y The y position of the rectangle
540+
* @param {number} w The width of the rectangle
541+
* @param {number} h The height of the rectangle
542+
* @param {string} transform The transform to apply, if any
543+
* @returns {HTMLElement} The generated rectangle element
544+
*/
545+
protected createRect(
546+
x: number,
547+
y: number,
548+
w: number,
549+
h: number,
550+
transform: string
551+
): HTMLElement {
552+
const padding = 40;
553+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
554+
rect.setAttribute(
555+
'data-sre-highlighter-added', // Mark highlighting rect.
556+
'true'
557+
);
558+
rect.setAttribute('x', String(x - padding));
559+
rect.setAttribute('y', String(y - padding));
560+
rect.setAttribute('width', String(w + 2 * padding));
561+
rect.setAttribute('height', String(h + 2 * padding));
562+
if (transform) {
563+
rect.setAttribute('transform', transform);
564+
}
565+
return rect as any as HTMLElement;
391566
}
392567

393568
/**
@@ -408,6 +583,9 @@ class SvgHighlighter extends AbstractHighlighter {
408583
}
409584

410585
class ChtmlHighlighter extends AbstractHighlighter {
586+
protected static lineSelector = 'mjx-linebox';
587+
protected static lineAttr = 'lineno';
588+
411589
/**
412590
* @override
413591
*/
@@ -426,7 +604,9 @@ class ChtmlHighlighter extends AbstractHighlighter {
426604
foreground: node.style.color,
427605
};
428606
if (!this.isHighlighted(node)) {
429-
node.style.backgroundColor = this.background;
607+
if (!node.hasAttribute('data-mjx-enclosed')) {
608+
node.style.backgroundColor = this.background;
609+
}
430610
node.style.color = this.foreground;
431611
}
432612
return info;
@@ -436,8 +616,34 @@ class ChtmlHighlighter extends AbstractHighlighter {
436616
* @override
437617
*/
438618
public unhighlightNode(info: Highlight) {
439-
info.node.style.backgroundColor = info.background;
440-
info.node.style.color = info.foreground;
619+
const node = info.node;
620+
node.style.backgroundColor = info.background;
621+
node.style.color = info.foreground;
622+
node.removeAttribute('data-mjx-enclosed');
623+
if (node.tagName.toLowerCase() === 'mjx-bbox') {
624+
node.remove();
625+
}
626+
}
627+
628+
/**
629+
* @override
630+
*/
631+
protected createEnclosure(
632+
x: number,
633+
y: number,
634+
w: number,
635+
h: number,
636+
node: HTMLElement
637+
): HTMLElement {
638+
const base = node.getBoundingClientRect();
639+
const enclosure = document.createElement('mjx-bbox');
640+
enclosure.style.width = w + 'px';
641+
enclosure.style.height = h + 'px';
642+
enclosure.style.left = x - base.left + 'px';
643+
enclosure.style.top = y - h - base.top + 'px';
644+
enclosure.style.position = 'absolute';
645+
node.prepend(enclosure);
646+
return enclosure;
441647
}
442648

443649
/**

0 commit comments

Comments
 (0)