site/node_modules/mathjax-full/ts/a11y/explorer.ts
2024-10-14 08:09:33 +02:00

651 lines
22 KiB
TypeScript

/*************************************************************
*
* Copyright (c) 2018-2022 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Mixin that implements the Explorer
*
* @author dpvc@mathjax.org (Davide Cervone)
*/
import {Handler} from '../core/Handler.js';
import {MmlNode} from '../core/MmlTree/MmlNode.js';
import {MathML} from '../input/mathml.js';
import {STATE, newState} from '../core/MathItem.js';
import {EnrichedMathItem, EnrichedMathDocument, EnrichHandler} from './semantic-enrich.js';
import {MathDocumentConstructor} from '../core/MathDocument.js';
import {OptionList, expandable} from '../util/Options.js';
import {SerializedMmlVisitor} from '../core/MmlTree/SerializedMmlVisitor.js';
import {MJContextMenu} from '../ui/menu/MJContextMenu.js';
import {Explorer} from './explorer/Explorer.js';
import * as ke from './explorer/KeyExplorer.js';
import * as me from './explorer/MouseExplorer.js';
import {TreeColorer, FlameColorer} from './explorer/TreeExplorer.js';
import {LiveRegion, ToolTip, HoverRegion} from './explorer/Region.js';
import {Submenu} from 'mj-context-menu/js/item_submenu.js';
import Sre from './sre.js';
/**
* Generic constructor for Mixins
*/
export type Constructor<T> = new(...args: any[]) => T;
/**
* Shorthands for types with HTMLElement, Text, and Document instead of generics
*/
export type HANDLER = Handler<HTMLElement, Text, Document>;
export type HTMLDOCUMENT = EnrichedMathDocument<HTMLElement, Text, Document>;
export type HTMLMATHITEM = EnrichedMathItem<HTMLElement, Text, Document>;
export type MATHML = MathML<HTMLElement, Text, Document>;
/*==========================================================================*/
/**
* Add STATE value for having the Explorer added (after TYPESET and before INSERTED or CONTEXT_MENU)
*/
newState('EXPLORER', 160);
/**
* The properties added to MathItem for the Explorer
*/
export interface ExplorerMathItem extends HTMLMATHITEM {
/**
* @param {HTMLDocument} document The document where the Explorer is being added
* @param {boolean} force True to force the explorer even if enableExplorer is false
*/
explorable(document: HTMLDOCUMENT, force?: boolean): void;
/**
* @param {HTMLDocument} document The document where the Explorer is being added
*/
attachExplorers(document: HTMLDOCUMENT): void;
}
/**
* The mixin for adding the Explorer to MathItems
*
* @param {B} BaseMathItem The MathItem class to be extended
* @param {Function} toMathML The function to serialize the internal MathML
* @returns {ExplorerMathItem} The Explorer MathItem class
*
* @template B The MathItem class to extend
*/
export function ExplorerMathItemMixin<B extends Constructor<HTMLMATHITEM>>(
BaseMathItem: B,
toMathML: (node: MmlNode) => string
): Constructor<ExplorerMathItem> & B {
return class extends BaseMathItem {
/**
* The Explorer objects for this math item
*/
protected explorers: {[key: string]: Explorer} = {};
/**
* The currently attached explorers
*/
protected attached: string[] = [];
/**
* True when a rerendered element should restart these explorers
*/
protected restart: string[] = [];
/**
* True when a rerendered element should regain the focus
*/
protected refocus: boolean = false;
/**
* Save explorer id during rerendering.
*/
protected savedId: string = null;
/**
* Add the explorer to the output for this math item
*
* @param {HTMLDocument} document The MathDocument for the MathItem
* @param {boolean} force True to force the explorer even if enableExplorer is false
*/
public explorable(document: ExplorerMathDocument, force: boolean = false) {
if (this.state() >= STATE.EXPLORER) return;
if (!this.isEscaped && (document.options.enableExplorer || force)) {
const node = this.typesetRoot;
const mml = toMathML(this.root);
if (this.savedId) {
this.typesetRoot.setAttribute('sre-explorer-id', this.savedId);
this.savedId = null;
}
// Init explorers:
this.explorers = initExplorers(document, node, mml);
this.attachExplorers(document);
}
this.state(STATE.EXPLORER);
}
/**
* Attaches the explorers that are currently meant to be active given
* the document options. Detaches all others.
* @param {ExplorerMathDocument} document The current document.
*/
public attachExplorers(document: ExplorerMathDocument) {
this.attached = [];
let keyExplorers = [];
for (let key of Object.keys(this.explorers)) {
let explorer = this.explorers[key];
if (explorer instanceof ke.AbstractKeyExplorer) {
explorer.AddEvents();
explorer.stoppable = false;
keyExplorers.unshift(explorer);
}
if (document.options.a11y[key]) {
explorer.Attach();
this.attached.push(key);
} else {
explorer.Detach();
}
}
// Ensure that the last currently attached key explorer stops propagating
// key events.
for (let explorer of keyExplorers) {
if (explorer.attached) {
explorer.stoppable = true;
break;
}
}
}
/**
* @override
*/
public rerender(document: ExplorerMathDocument, start: number = STATE.RERENDER) {
this.savedId = this.typesetRoot.getAttribute('sre-explorer-id');
this.refocus = (window.document.activeElement === this.typesetRoot);
for (let key of this.attached) {
let explorer = this.explorers[key];
if (explorer.active) {
this.restart.push(key);
explorer.Stop();
}
}
super.rerender(document, start);
}
/**
* @override
*/
public updateDocument(document: ExplorerMathDocument) {
super.updateDocument(document);
this.refocus && this.typesetRoot.focus();
this.restart.forEach(x => this.explorers[x].Start());
this.restart = [];
this.refocus = false;
}
};
}
/**
* The functions added to MathDocument for the Explorer
*/
export interface ExplorerMathDocument extends HTMLDOCUMENT {
/**
* The objects needed for the explorer
*/
explorerRegions: ExplorerRegions;
/**
* Add the Explorer to the MathItems in the MathDocument
*
* @returns {MathDocument} The MathDocument (so calls can be chained)
*/
explorable(): HTMLDOCUMENT;
}
/**
* The mixin for adding the Explorer to MathDocuments
*
* @param {B} BaseDocument The MathDocument class to be extended
* @returns {ExplorerMathDocument} The extended MathDocument class
*/
export function ExplorerMathDocumentMixin<B extends MathDocumentConstructor<HTMLDOCUMENT>>(
BaseDocument: B
): MathDocumentConstructor<ExplorerMathDocument> & B {
return class extends BaseDocument {
/**
* @override
*/
public static OPTIONS: OptionList = {
...BaseDocument.OPTIONS,
enableExplorer: true,
renderActions: expandable({
...BaseDocument.OPTIONS.renderActions,
explorable: [STATE.EXPLORER]
}),
sre: expandable({
...BaseDocument.OPTIONS.sre,
speech: 'shallow', // overrides option in EnrichedMathDocument
}),
a11y: {
align: 'top', // placement of magnified expression
backgroundColor: 'Blue', // color for background of selected sub-expression
backgroundOpacity: 20, // opacity for background of selected sub-expression
braille: false, // switch on Braille output
flame: false, // color collapsible sub-expressions
foregroundColor: 'Black', // color to use for text of selected sub-expression
foregroundOpacity: 100, // opacity for text of selected sub-expression
highlight: 'None', // type of highlighting for collapsible sub-expressions
hover: false, // show collapsible sub-expression on mouse hovering
infoPrefix: false, // show speech prefixes on mouse hovering
infoRole: false, // show semantic role on mouse hovering
infoType: false, // show semantic type on mouse hovering
keyMagnifier: false, // switch on magnification via key exploration
magnification: 'None', // type of magnification
magnify: '400%', // percentage of magnification of zoomed expressions
mouseMagnifier: false, // switch on magnification via mouse hovering
speech: true, // switch on speech output
subtitles: true, // show speech as a subtitle
treeColoring: false, // tree color expression
viewBraille: false // display Braille output as subtitles
}
};
/**
* The objects needed for the explorer
*/
public explorerRegions: ExplorerRegions;
/**
* Extend the MathItem class used for this MathDocument
* and create the visitor and explorer objects needed for the explorer
*
* @override
* @constructor
*/
constructor(...args: any[]) {
super(...args);
const ProcessBits = (this.constructor as typeof BaseDocument).ProcessBits;
if (!ProcessBits.has('explorer')) {
ProcessBits.allocate('explorer');
}
const visitor = new SerializedMmlVisitor(this.mmlFactory);
const toMathML = ((node: MmlNode) => visitor.visitTree(node));
this.options.MathItem = ExplorerMathItemMixin(this.options.MathItem, toMathML);
// TODO: set backward compatibility options here.
this.explorerRegions = initExplorerRegions(this);
}
/**
* Add the Explorer to the MathItems in this MathDocument
*
* @return {ExplorerMathDocument} The MathDocument (so calls can be chained)
*/
public explorable(): ExplorerMathDocument {
if (!this.processed.isSet('explorer')) {
if (this.options.enableExplorer) {
for (const math of this.math) {
(math as ExplorerMathItem).explorable(this);
}
}
this.processed.set('explorer');
}
return this;
}
/**
* @override
*/
public state(state: number, restore: boolean = false) {
super.state(state, restore);
if (state < STATE.EXPLORER) {
this.processed.clear('explorer');
}
return this;
}
};
}
/*==========================================================================*/
/**
* Add Explorer functions to a Handler instance
*
* @param {Handler} handler The Handler instance to enhance
* @param {MathML} MmlJax A MathML input jax to be used for the semantic enrichment
* @returns {Handler} The handler that was modified (for purposes of chainging extensions)
*/
export function ExplorerHandler(handler: HANDLER, MmlJax: MATHML = null): HANDLER {
if (!handler.documentClass.prototype.enrich && MmlJax) {
handler = EnrichHandler(handler, MmlJax);
}
handler.documentClass = ExplorerMathDocumentMixin(handler.documentClass as any);
return handler;
}
/*==========================================================================*/
/**
* The regions objects needed for the explorers.
*/
export type ExplorerRegions = {
speechRegion?: LiveRegion,
brailleRegion?: LiveRegion,
magnifier?: HoverRegion,
tooltip1?: ToolTip,
tooltip2?: ToolTip,
tooltip3?: ToolTip
};
/**
* Initializes the regions needed for a document.
* @param {ExplorerMathDocument} document The current document.
*/
function initExplorerRegions(document: ExplorerMathDocument) {
return {
speechRegion: new LiveRegion(document),
brailleRegion: new LiveRegion(document),
magnifier: new HoverRegion(document),
tooltip1: new ToolTip(document),
tooltip2: new ToolTip(document),
tooltip3: new ToolTip(document)
};
}
/**
* Type of explorer initialization methods.
* @type {(ExplorerMathDocument, HTMLElement, any[]): Explorer}
*/
type ExplorerInit = (doc: ExplorerMathDocument,
node: HTMLElement, ...rest: any[]) => Explorer;
/**
* Generation methods for all MathJax explorers available via option settings.
*/
let allExplorers: {[options: string]: ExplorerInit} = {
speech: (doc: ExplorerMathDocument, node: HTMLElement, ...rest: any[]) => {
let explorer = ke.SpeechExplorer.create(
doc, doc.explorerRegions.speechRegion, node, ...rest) as ke.SpeechExplorer;
explorer.speechGenerator.setOptions({
locale: doc.options.sre.locale, domain: doc.options.sre.domain,
style: doc.options.sre.style, modality: 'speech'});
// This weeds out the case of providing a non-existent locale option.
let locale = explorer.speechGenerator.getOptions().locale;
if (locale !== Sre.engineSetup().locale) {
doc.options.sre.locale = Sre.engineSetup().locale;
explorer.speechGenerator.setOptions({locale: doc.options.sre.locale});
}
explorer.showRegion = 'subtitles';
return explorer;
},
braille: (doc: ExplorerMathDocument, node: HTMLElement, ...rest: any[]) => {
let explorer = ke.SpeechExplorer.create(
doc, doc.explorerRegions.brailleRegion, node, ...rest) as ke.SpeechExplorer;
explorer.speechGenerator.setOptions({locale: 'nemeth', domain: 'default',
style: 'default', modality: 'braille'});
explorer.showRegion = 'viewBraille';
return explorer;
},
keyMagnifier: (doc: ExplorerMathDocument, node: HTMLElement, ...rest: any[]) =>
ke.Magnifier.create(doc, doc.explorerRegions.magnifier, node, ...rest),
mouseMagnifier: (doc: ExplorerMathDocument, node: HTMLElement, ..._rest: any[]) =>
me.ContentHoverer.create(doc, doc.explorerRegions.magnifier, node,
(x: HTMLElement) => x.hasAttribute('data-semantic-type'),
(x: HTMLElement) => x),
hover: (doc: ExplorerMathDocument, node: HTMLElement, ..._rest: any[]) =>
me.FlameHoverer.create(doc, null, node),
infoType: (doc: ExplorerMathDocument, node: HTMLElement, ..._rest: any[]) =>
me.ValueHoverer.create(doc, doc.explorerRegions.tooltip1, node,
(x: HTMLElement) => x.hasAttribute('data-semantic-type'),
(x: HTMLElement) => x.getAttribute('data-semantic-type')),
infoRole: (doc: ExplorerMathDocument, node: HTMLElement, ..._rest: any[]) =>
me.ValueHoverer.create(doc, doc.explorerRegions.tooltip2, node,
(x: HTMLElement) => x.hasAttribute('data-semantic-role'),
(x: HTMLElement) => x.getAttribute('data-semantic-role')),
infoPrefix: (doc: ExplorerMathDocument, node: HTMLElement, ..._rest: any[]) =>
me.ValueHoverer.create(doc, doc.explorerRegions.tooltip3, node,
(x: HTMLElement) => x.hasAttribute('data-semantic-prefix'),
(x: HTMLElement) => x.getAttribute('data-semantic-prefix')),
flame: (doc: ExplorerMathDocument, node: HTMLElement, ..._rest: any[]) =>
FlameColorer.create(doc, null, node),
treeColoring: (doc: ExplorerMathDocument, node: HTMLElement, ...rest: any[]) =>
TreeColorer.create(doc, null, node, ...rest)
};
/**
* Initialises explorers for a document.
* @param {ExplorerMathDocument} document The target document.
* @param {HTMLElement} node The node explorers will be attached to.
* @param {string} mml The corresponding Mathml node as a string.
* @return {Explorer[]} A list of initialised explorers.
*/
function initExplorers(document: ExplorerMathDocument, node: HTMLElement, mml: string): {[key: string]: Explorer} {
let explorers: {[key: string]: Explorer} = {};
for (let key of Object.keys(allExplorers)) {
explorers[key] = allExplorers[key](document, node, mml);
}
return explorers;
}
/* Context Menu Interactions */
/**
* Sets a list of a11y options for a given document.
* @param {HTMLDOCUMENT} document The current document.
* @param {{[key: string]: any}} options Association list for a11y option value pairs.
*/
export function setA11yOptions(document: HTMLDOCUMENT, options: {[key: string]: any}) {
let sreOptions = Sre.engineSetup() as {[name: string]: string};
for (let key in options) {
if (document.options.a11y[key] !== undefined) {
setA11yOption(document, key, options[key]);
if (key === 'locale') {
document.options.sre[key] = options[key];
}
continue;
}
if (sreOptions[key] !== undefined) {
document.options.sre[key] = options[key];
}
}
// Reinit explorers
for (let item of document.math) {
(item as ExplorerMathItem).attachExplorers(document as ExplorerMathDocument);
}
}
/**
* Sets a single a11y option for a menu name.
* @param {HTMLDOCUMENT} document The current document.
* @param {string} option The option name in the menu.
* @param {string|boolean} value The new value.
*/
export function setA11yOption(document: HTMLDOCUMENT, option: string, value: string | boolean) {
switch (option) {
case 'magnification':
switch (value) {
case 'None':
document.options.a11y.magnification = value;
document.options.a11y.keyMagnifier = false;
document.options.a11y.mouseMagnifier = false;
break;
case 'Keyboard':
document.options.a11y.magnification = value;
document.options.a11y.keyMagnifier = true;
document.options.a11y.mouseMagnifier = false;
break;
case 'Mouse':
document.options.a11y.magnification = value;
document.options.a11y.keyMagnifier = false;
document.options.a11y.mouseMagnifier = true;
break;
}
break;
case 'highlight':
switch (value) {
case 'None':
document.options.a11y.highlight = value;
document.options.a11y.hover = false;
document.options.a11y.flame = false;
break;
case 'Hover':
document.options.a11y.highlight = value;
document.options.a11y.hover = true;
document.options.a11y.flame = false;
break;
case 'Flame':
document.options.a11y.highlight = value;
document.options.a11y.hover = false;
document.options.a11y.flame = true;
break;
}
break;
default:
document.options.a11y[option] = value;
}
}
/**
* Values for the ClearSpeak preference variables.
*/
let csPrefsSetting: {[pref: string]: string} = {};
/**
* Generator of all variables for the Clearspeak Preference settings.
* @param {MJContextMenu} menu The current context menu.
* @param {string[]} prefs The preferences.
*/
let csPrefsVariables = function(menu: MJContextMenu, prefs: string[]) {
let srVariable = menu.pool.lookup('speechRules');
for (let pref of prefs) {
if (csPrefsSetting[pref]) continue;
menu.factory.get('variable')(menu.factory, {
name: 'csprf_' + pref,
setter: (value: string) => {
csPrefsSetting[pref] = value;
srVariable.setValue(
'clearspeak-' +
Sre.clearspeakPreferences.addPreference(
Sre.clearspeakStyle(), pref, value)
);
},
getter: () => { return csPrefsSetting[pref] || 'Auto'; }
}, menu.pool);
}
};
/**
* Generate the selection box for the Clearspeak Preferences.
* @param {MJContextMenu} menu The current context menu.
* @param {string} locale The current locale.
*/
let csSelectionBox = function(menu: MJContextMenu, locale: string) {
let prefs = Sre.clearspeakPreferences.getLocalePreferences();
let props = prefs[locale];
if (!props) {
let csEntry = menu.findID('Accessibility', 'Speech', 'Clearspeak');
if (csEntry) {
csEntry.disable();
}
return null;
}
csPrefsVariables(menu, Object.keys(props));
let items = [];
for (const prop of Object.getOwnPropertyNames(props)) {
items.push({
'title': prop,
'values': props[prop].map(x => x.replace(RegExp('^' + prop + '_'), '')),
'variable': 'csprf_' + prop
});
}
let sb = menu.factory.get('selectionBox')(menu.factory, {
'title': 'Clearspeak Preferences',
'signature': '',
'order': 'alphabetic',
'grid': 'square',
'selections': items
}, menu);
return {'type': 'command',
'id': 'ClearspeakPreferences',
'content': 'Select Preferences',
'action': () => sb.post(0, 0)};
};
/**
* Creates dynamic clearspeak menu.
* @param {MJContextMenu} menu The context menu.
* @param {Submenu} sub The submenu to attach.
*/
let csMenu = function(menu: MJContextMenu, sub: Submenu) {
let locale = menu.pool.lookup('locale').getValue() as string;
const box = csSelectionBox(menu, locale);
let items: Object[] = [];
try {
items = Sre.clearspeakPreferences.smartPreferences(
menu.mathItem, locale);
} catch (e) {
console.log(e);
}
if (box) {
items.splice(2, 0, box);
}
return menu.factory.get('subMenu')(menu.factory, {
items: items,
id: 'Clearspeak'
}, sub);
};
MJContextMenu.DynamicSubmenus.set('Clearspeak', csMenu);
/**
* Creates dynamic locale menu.
* @param {MJContextMenu} menu The context menu.
* @param {Submenu} sub The submenu to attach.
*/
let language = function(menu: MJContextMenu, sub: Submenu) {
let radios: {type: string, id: string,
content: string, variable: string}[] = [];
for (let lang of Sre.locales.keys()) {
if (lang === 'nemeth') continue;
radios.push({type: 'radio', id: lang,
content: Sre.locales.get(lang) || lang, variable: 'locale'});
}
radios.sort((x, y) => x.content.localeCompare(y.content, 'en'));
return menu.factory.get('subMenu')(menu.factory, {
items: radios, id: 'Language'}, sub);
};
MJContextMenu.DynamicSubmenus.set('A11yLanguage', language);