diff --git a/components/mjs/core/config.json b/components/mjs/core/config.json index 7f032dbc4..87e95dc00 100644 --- a/components/mjs/core/config.json +++ b/components/mjs/core/config.json @@ -4,6 +4,8 @@ "targets": [ "mathjax.ts", "core", "util", "handlers", + "ui/dialog/DraggableDialog.ts", + "ui/dialog/InfoDialog.ts", "adaptors/HTMLAdaptor.ts", "adaptors/browserAdaptor.ts", "components/global.ts" diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 7964807c0..8df549bc6 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -321,6 +321,7 @@ export function ExplorerMathDocumentMixin< public static OPTIONS: OptionList = { ...BaseDocument.OPTIONS, enableExplorer: hasWindow, // only activate in interactive contexts + enableExplorerHelp: true, // help dialog is enabled renderActions: expandable({ ...BaseDocument.OPTIONS.renderActions, explorable: [STATE.EXPLORER] @@ -417,78 +418,6 @@ export function ExplorerMathDocumentMixin< display: 'inline-flex', 'align-items': 'center', }, - - 'mjx-help-sizer': { - position: 'fixed', - width: '40%', - 'max-width': '30em', - top: '3em', - left: '50%', - }, - 'mjx-help-dialog': { - position: 'absolute', - width: '200%', - left: '-100%', - border: '3px outset', - 'border-radius': '15px', - color: 'black', - 'background-color': '#DDDDDD', - 'z-index': '301', - 'text-align': 'right', - 'font-style': 'normal', - 'text-indent': 0, - 'text-transform': 'none', - 'line-height': 'normal', - 'letter-spacing': 'normal', - 'word-spacing': 'normal', - 'word-wrap': 'normal', - float: 'none', - 'box-shadow': '0px 10px 20px #808080', - outline: 'none', - }, - 'mjx-help-dialog > h1': { - 'font-size': '24px', - 'text-align': 'center', - margin: '.5em 0', - }, - 'mjx-help-dialog > div': { - margin: '0 1em', - padding: '3px', - overflow: 'auto', - height: '20em', - border: '2px inset black', - 'background-color': 'white', - 'text-align': 'left', - }, - 'mjx-help-dialog > input': { - margin: '.5em 2em', - }, - 'mjx-help-dialog kbd': { - display: 'inline-block', - padding: '3px 5px', - 'font-size': '11px', - 'line-height': '10px', - color: '#444d56', - 'vertical-align': 'middle', - 'background-color': '#fafbfc', - border: 'solid 1.5px #c6cbd1', - 'border-bottom-color': '#959da5', - 'border-radius': '3px', - 'box-shadow': 'inset -.5px -1px 0 #959da5', - }, - 'mjx-help-dialog ul': { - 'list-style-type': 'none', - }, - 'mjx-help-dialog li': { - 'margin-bottom': '.5em', - }, - 'mjx-help-background': { - position: 'fixed', - top: 0, - left: 0, - right: 0, - bottom: 0, - }, }; /** @@ -556,7 +485,7 @@ export function ExplorerMathDocumentMixin< SVGNS ), ]); - this.tmpFocus = this.adaptor.node('mjx-focus', { + this.tmpFocus = adaptor.node('mjx-focus', { tabIndex: 0, style: { outline: 'none', diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 943def94d..e333db800 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -30,6 +30,9 @@ import { MmlNode } from '../../core/MmlTree/MmlNode.js'; import { honk, SemAttr } from '../speech/SpeechUtil.js'; import { GeneratorPool } from '../speech/GeneratorPool.js'; import { context } from '../../util/context.js'; +import { InfoDialog } from '../../ui/dialog/InfoDialog.js'; + +/**********************************************************************/ /** * Interface for keyboard explorers. Adds the necessary keyboard events. @@ -70,7 +73,7 @@ export interface KeyExplorer extends Explorer { /** * Type of function that implements a key press action */ -type keyMapping = ( +export type keyMapping = ( explorer: SpeechExplorer, event: KeyboardEvent ) => boolean | void; @@ -106,149 +109,162 @@ export function hasModifiers( ); } +/**********************************************************************/ /**********************************************************************/ /** - * Creates a customized help dialog + * @class + * @augments {AbstractExplorer} * - * @param {string} title The title to use for the message - * @param {string} select Additional ways to select the typeset math - * @returns {string} The customized message + * @template T The type that is consumed by the Region of this explorer. */ -function helpMessage(title: string, select: string): string { - return ` -

Exploring expressions ${title}

+export class SpeechExplorer + extends AbstractExplorer + implements KeyExplorer +{ + /** + * Creates a customized help dialog + * + * @param {string} title The title to use for the message + * @param {string} select Additional ways to select the typeset math + * @returns {string} The customized message + */ + protected static helpMessage(title: string, select: string): string { + return ` +

Exploring expressions ${title}

-

The mathematics on this page is being rendered by MathJax, which -generates both the text spoken by screen readers, as well as the -visual layout for sighted users.

+

The mathematics on this page is being rendered by MathJax, which + generates both the text spoken by screen readers, as well as the + visual layout for sighted users.

-

Expressions typeset by MathJax can be explored interactively, and -are focusable. You can use the Tab key to move to a typeset -expression${select}. Initially, the expression will be read in full, -but you can use the following keys to explore the expression -further:

+

Expressions typeset by MathJax can be explored interactively, and + are focusable. You can use the Tab key to move to a typeset + expression${select}. Initially, the expression will be read in full, + but you can use the following keys to explore the expression + further:

- -

The MathJax contextual menu allows you to enable or disable speech -or Braille generation for mathematical expressions, the language to -use for the spoken mathematics, and other features of MathJax. In -particular, the Explorer submenu allows you to specify how the -mathematics should be identified in the page (e.g., by saying "math" -when the expression is spoken), and whether or not to include a -message about the letter "h" bringing up this dialog box.

+

The MathJax contextual menu allows you to enable or disable speech + or Braille generation for mathematical expressions, the language to + use for the spoken mathematics, and other features of MathJax. In + particular, the Explorer submenu allows you to specify how the + mathematics should be identified in the page (e.g., by saying "math" + when the expression is spoken), and whether or not to include a + message about the letter "h" bringing up this dialog box. Turning off + speech and Braille will disable the expression explorer, its + highlighting, and its help icon.

-

The contextual menu also provides options for viewing or copying a -MathML version of the expression or its original source format, -creating an SVG version of the expression, and viewing various other -information.

+

The contextual menu also provides options for viewing or copying a + MathML version of the expression or its original source format, + creating an SVG version of the expression, and viewing various other + information.

-

For more help, see the MathJax accessibility documentation.

-`; -} +

For more help, see the MathJax accessibility documentation.

+ `; + } -/** - * Help for the different OS versions - */ -const helpData: Map = new Map([ - [ - 'MacOS', + /** + * Help for the different OS versions + */ + protected static helpData: Map = new Map([ [ - 'on MacOS and iOS using VoiceOver', - ', or the VoiceOver arrow keys to select an expression', + 'MacOS', + [ + 'on MacOS and iOS using VoiceOver', + ', or the VoiceOver arrow keys to select an expression', + ], ], - ], - [ - 'Windows', [ - 'in Windows using NVDA or JAWS', - `. The screen reader should enter focus or forms mode automatically -when the expression gets the browser focus, but if not, you can toggle -focus mode using NVDA+space in NVDA; for JAWS, Enter should start -forms mode while Numpad Plus leaves it. Also note that you can use -the NVDA or JAWS key plus the arrow keys to explore the expression -even in browse mode, and you can use NVDA+shift+arrow keys to -navigate out of an expression that has the focus in NVDA`, + 'Windows', + [ + 'in Windows using NVDA or JAWS', + `. The screen reader should enter focus or forms mode automatically + when the expression gets the browser focus, but if not, you can toggle + focus mode using NVDA+space in NVDA; for JAWS, Enter should start + forms mode while Numpad Plus leaves it. Also note that you can use + the NVDA or JAWS key plus the arrow keys to explore the expression + even in browse mode, and you can use NVDA+shift+arrow keys to + navigate out of an expression that has the focus in NVDA`, + ], ], - ], - [ - 'Unix', [ - 'in Unix using Orca', - `, and Orca should enter focus mode automatically. If not, use the -Orca+a key to toggle focus mode on or off. Also note that you can use -Orca+arrow keys to explore expressions even in browse mode`, + 'Unix', + [ + 'in Unix using Orca', + `, and Orca should enter focus mode automatically. If not, use the + Orca+a key to toggle focus mode on or off. Also note that you can use + Orca+arrow keys to explore expressions even in browse mode`, + ], ], - ], - ['unknown', ['with a Screen Reader.', '']], -]); - -/**********************************************************************/ -/**********************************************************************/ + ['unknown', ['with a Screen Reader.', '']], + ]); -/** - * @class - * @augments {AbstractExplorer} - * - * @template T The type that is consumed by the Region of this explorer. - */ -export class SpeechExplorer - extends AbstractExplorer - implements KeyExplorer -{ /* * The explorer key mapping */ @@ -605,8 +621,13 @@ export class SpeechExplorer /** * Open the help dialog, and refocus when it closes. + * + * @returns {boolean | void} True cancels the event */ - protected hKey() { + protected hKey(): boolean | void { + if (!this.document.options.enableExplorerHelp) { + return true; + } this.refocus = this.current; this.help(); } @@ -933,40 +954,36 @@ export class SpeechExplorer * Displays the help dialog. */ protected help() { - const adaptor = this.document.adaptor; - const helpBackground = adaptor.node('mjx-help-background'); - const close = (event: Event) => { - helpBackground.remove(); - this.node.focus(); - this.stopEvent(event); - }; - helpBackground.addEventListener('click', close); - const helpSizer = adaptor.node('mjx-help-sizer', {}, [ - adaptor.node( - 'mjx-help-dialog', - { tabindex: 0, role: 'dialog', 'aria-labeledby': 'mjx-help-label' }, - [ - adaptor.node('h1', { id: 'mjx-help-label' }, [ - adaptor.text('MathJax Expression Explorer Help'), - ]), - adaptor.node('div'), - adaptor.node('input', { type: 'button', value: 'Close' }), - ] - ), - ]); - helpBackground.append(helpSizer); - const help = helpSizer.firstChild as HTMLElement; - help.addEventListener('click', (event) => this.stopEvent(event)); - help.lastChild.addEventListener('click', close); - help.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.code === 'Escape') { - close(event); - } + if (!this.document.options.enableExplorerHelp) { + return; + } + const CLASS = this.constructor as typeof SpeechExplorer; + const [title, select] = CLASS.helpData.get(context.os); + InfoDialog.post({ + title: 'MathJax Expression Explorer Help', + message: CLASS.helpMessage(title, select), + node: this.node, + adaptor: this.document.adaptor, + styles: { + '.mjx-dialog': { + 'max-height': 'calc(min(35em, 90%))', + }, + 'mjx-dialog mjx-title': { + 'font-size': '133%', + margin: '.5em 1.75em', + }, + 'mjx-dialog h2': { + 'font-size': '20px', + margin: '.5em 0', + }, + 'mjx-dialog ul': { + 'list-style-type': 'none', + }, + 'mjx-dialog li': { + 'margin-bottom': '.5em', + }, + }, }); - const [title, select] = helpData.get(context.os); - (help.childNodes[1] as HTMLElement).innerHTML = helpMessage(title, select); - document.body.append(helpBackground); - help.focus(); } /********************************************************************/ @@ -1062,7 +1079,10 @@ export class SpeechExplorer if (describe) { let description = this.description === this.none ? '' : ', ' + this.description; - if (this.document.options.a11y.help) { + if ( + this.document.options.a11y.help && + this.document.options.enableExplorerHelp + ) { description += ', press h for help'; } speech += description; @@ -1538,7 +1558,9 @@ export class SpeechExplorer // and add the info icon. // this.node.classList.add('mjx-explorer-active'); - this.node.append(this.document.infoIcon); + if (this.document.options.enableExplorerHelp) { + this.node.append(this.document.infoIcon); + } // // Get the node to make current, and determine if we need to add a // speech node (or just use the top-level node), then set the @@ -1574,7 +1596,9 @@ export class SpeechExplorer this.node.setAttribute('aria-roledescription', description); } this.node.classList.remove('mjx-explorer-active'); - this.document.infoIcon.remove(); + if (this.document.options.enableExplorerHelp) { + this.document.infoIcon.remove(); + } this.pool.unhighlight(); this.magnifyRegion.Hide(); this.region.Hide(); diff --git a/ts/a11y/speech/SpeechMenu.ts b/ts/a11y/speech/SpeechMenu.ts index 97663197d..e76a927cc 100644 --- a/ts/a11y/speech/SpeechMenu.ts +++ b/ts/a11y/speech/SpeechMenu.ts @@ -22,7 +22,12 @@ */ import { ExplorerMathItem } from '../explorer.js'; -import { MJContextMenu } from '../../ui/menu/MJContextMenu.js'; +import { MJContextMenu, SubmenuCallback } from '../../ui/menu/MJContextMenu.js'; +import { + SelectionDialog, + SelectionOrder, + SelectionGrid, +} from '../../ui/dialog/SelectionDialog.js'; import { SubMenu, Submenu } from '../../ui/menu/mj-context-menu.js'; import * as Sre from '../sre.js'; @@ -113,7 +118,7 @@ let counter = 0; function csSelectionBox(menu: MJContextMenu, locale: string): object { const props = localePreferences.get(locale); csPrefsVariables(menu, Object.keys(props)); - const items = []; + const items: any[] = []; for (const prop of Object.getOwnPropertyNames(props)) { items.push({ title: prop, @@ -121,22 +126,19 @@ function csSelectionBox(menu: MJContextMenu, locale: string): object { variable: 'csprf_' + prop, }); } - const sb = menu.factory.get('selectionBox')( - menu.factory, - { - title: 'Clearspeak Preferences', - signature: '', - order: 'alphabetic', - grid: 'square', - selections: items, - }, + const sb = new SelectionDialog( + 'Clearspeak Preferences', + '', + items, + SelectionOrder.ALPHABETICAL, + SelectionGrid.SQUARE, menu ); return { type: 'command', id: 'ClearspeakPreferences', content: 'Select Preferences', - action: () => sb.post(0, 0), + action: () => sb.post(), }; } @@ -223,13 +225,13 @@ function smartPreferences( * * @param {MJContextMenu} menu The context menu. * @param {Submenu} sub The submenu to attach elements to. - * @param {(sub: SubMenu) => void} callback Callback to apply on the constructed + * @param {SubmenuCallback} callback Callback to apply on the constructed * submenu. */ export async function clearspeakMenu( menu: MJContextMenu, sub: Submenu, - callback: (sub: SubMenu) => void + callback: SubmenuCallback ) { const exit = (items: object[]) => { callback( @@ -288,13 +290,13 @@ let LOCALE_MENU: SubMenu = null; * * @param {MJContextMenu} menu The context menu. * @param {Submenu} sub The submenu to attach elements to. - * @param {(sub: SubMenu) => void} callback Callback to apply on the constructed + * @param {SubmenuCallback} callback Callback to apply on the constructed * submenu. */ export function localeMenu( menu: MJContextMenu, sub: Submenu, - callback: (sub: SubMenu) => void + callback: SubmenuCallback ) { if (LOCALE_MENU) { callback(LOCALE_MENU); diff --git a/ts/ui/dialog/CopyDialog.ts b/ts/ui/dialog/CopyDialog.ts new file mode 100644 index 000000000..d31caebc3 --- /dev/null +++ b/ts/ui/dialog/CopyDialog.ts @@ -0,0 +1,77 @@ +/************************************************************* + * + * Copyright (c) 2025 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. + */ + +/** + * @file Implements the CopyDialog class (InfoDialog with copy button). + * + * @author dpvc@mathjax.org (Davide Cervone) + */ + +import { InfoDialog, InfoDialogArgs } from './InfoDialog.js'; + +/** + * The args for a CopyDialog + */ +export type CopyDialogArgs = InfoDialogArgs & { code?: boolean }; + +/** + * The CopyDialog subclass of InfoDialog + */ +export class CopyDialog extends InfoDialog { + /** + * @override + */ + public static post(args: CopyDialogArgs) { + return super.post(args); + } + + /** + * @override + */ + protected html(args: CopyDialogArgs) { + // + // Add a copy-to-clipboard button + // + args.extraNodes ??= []; + const copy = args.adaptor.node('input', { + type: 'button', + value: 'Copy to Clipboard', + 'data-drag': 'none', + }); + copy.addEventListener('click', this.copyToClipboard.bind(this)); + args.extraNodes.push(copy); + // + // If this is a code dialog, format the source and set in a pre element + // + if (args.code) { + args.message = '
' + this.formatSource(args.message) + '
'; + } + return super.html(args); + } + + /** + * @param {string} text The text to be displayed in the Info box + * @returns {string} The text with HTML specials being escaped + */ + protected formatSource(text: string): string { + return text + .trim() + .replace(/&/g, '&') + .replace(//g, '>'); + } +} diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts new file mode 100644 index 000000000..6575376e9 --- /dev/null +++ b/ts/ui/dialog/DraggableDialog.ts @@ -0,0 +1,1114 @@ +/************************************************************* + * + * Copyright (c) 2025 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. + */ + +/** + * @file Implements a draggable dialog class. + * + * @author dpvc@mathjax.org (Davide Cervone) + */ + +import { DOMAdaptor } from '../../core/DOMAdaptor.js'; +import { StyleJson, StyleJsonSheet } from '../../util/StyleJson.js'; +import { context } from '../../util/context.js'; + +export type ADAPTOR = DOMAdaptor; + +export type Action = ( + dialog: DraggableDialog, + event: MouseEvent +) => void | number[]; +export type ActionList = { [action: string]: Action }; +export type ActionMap = { [type: string]: ActionList }; + +/** + * The arguments that control the dialog contents + */ +export type DialogArgs = { + title?: string; // // the dialog title HTML + message: string; // // the dialog message HTML + adaptor: ADAPTOR; // // the adaptor to use to create the dialog + node?: HTMLElement; // // the node to focus when the dialog closes, if any + styles?: StyleJson; // // extra styles to use for the dialog + extraNodes?: HTMLElement[]; // // extra HTML nodes to put at the bottom of the dialog + className?: string; // // optional class to apply to the dialog +}; + +/** + * Type of function that implements a key press action + */ +export type keyMapping = ( + dialog: DraggableDialog, + event: KeyboardEvent +) => void; + +/** + * True if we can rely on an HTML dialog element. + */ +export const isDialog: boolean = !!context.window?.HTMLDialogElement; + +/*========================================================================*/ + +/** + * The draggable dialog class + */ +export class DraggableDialog { + /** + * The minimum width of the dialog + */ + protected minW = 200; + /** + * The maximum width of the dialog + */ + protected minH = 80; + + /** + * The current x translation of the dialog + */ + protected tx: number = 0; + /** + * The current y translation of the dialog + */ + protected ty: number = 0; + + /** + * The current mouse x position + */ + protected x: number; + /** + * The current mouse y position + */ + protected y: number; + /** + * The current dialog width + */ + protected w: number; + /** + * The current dialog height + */ + protected h: number; + /** + * True when the dialog is being dragged or sized + */ + protected dragging: boolean = false; + /** + * The drag acction being taken (move, left, right, top, bottom, etc.) + */ + protected action: string; + + /** + * Elements where clicking doesn't cause dragging + */ + protected noDrag: HTMLElement[]; + /** + * The title element + */ + protected title: HTMLElement; + /** + * The content div element + */ + protected content: HTMLElement; + + /** + * The node to focus when dialog closes + */ + protected node: HTMLElement; + /** + * The background element when is not available + */ + protected background: HTMLElement; + /** + * The dialog element node + */ + protected dialog: HTMLDialogElement; + + /** + * Events to add when dragging and remove when drag completes + */ + protected events = [ + ['mousemove', this.MouseMove.bind(this)], + ['mouseup', this.MouseUp.bind(this)], + ]; + + /* + * Key bindings for when dialog is open + */ + protected static keyActions: Map = new Map([ + ['Escape', (dialog, event) => dialog.escKey(event)], + ['a', (dialog, event) => dialog.aKey(event)], + ['m', (dialog, event) => dialog.mKey(event)], + ['s', (dialog, event) => dialog.sKey(event)], + ['ArrowRight', (dialog, event) => dialog.arrowKey(event, 'right')], + ['ArrowLeft', (dialog, event) => dialog.arrowKey(event, 'left')], + ['ArrowUp', (dialog, event) => dialog.arrowKey(event, 'up')], + ['ArrowDown', (dialog, event) => dialog.arrowKey(event, 'down')], + ]); + + /** + * The style element ID for dialog styles + */ + public static styleId: string = 'MJX-DIALOG-styles'; + /** + * The class name to use for the dialog, if any + */ + public static className: string = ''; + + /** + * An id incremented for each instance of a dialog + */ + public static id: number = 0; + + /** + * The default styles for all dialogs + */ + public static styles: StyleJson = { + // + // For when dialog element is not available + // + 'mjx-dialog-background': { + display: 'flex', + 'flex-direction': 'column', + 'justify-content': 'center', + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + 'z-index': 1001, + }, + + // + // The main dialog box and background + // + '.mjx-dialog': { + 'max-width': 'calc(min(60em, 90%))', + 'max-height': 'calc(min(50em, 85%))', + border: '3px outset', + 'border-radius': '15px', + color: 'black', + 'background-color': '#DDDDDD', + 'box-shadow': '0px 10px 20px #808080', + padding: '4px 4px', + cursor: 'grab', + overflow: 'visible', + display: 'flex', + 'flex-direction': 'column', + 'align-items': 'center', + position: 'relative', + top: '-4%', + }, + '.mjx-dialog.mjx-moving': { + cursor: 'grabbing', + }, + '.mjx-dialog > input[type="button"]': { + width: 'fit-content', + }, + '.mjx-dialog > mjx-dialog-spacer': { + display: 'block', + height: '.75em', + 'flex-shrink': 0, + }, + '.mjx-dialog::backdrop': { + opacity: 0, + cursor: 'default', + }, + + // + // The contents of the dialog + // + 'mjx-dialog': { + all: 'initial', + cursor: 'inherit', + width: '100%', + display: 'flex', + 'flex-direction': 'column', + 'flex-grow': 1, + 'flex-shrink': 1, + overflow: 'hidden', + }, + 'mjx-dialog > mjx-title': { + display: 'block', + 'text-align': 'center', + margin: '.25em 1.75em', + overflow: 'hidden', + 'white-space': 'nowrap', + '-webkit-user-select': 'none', + 'user-select': 'none', + 'flex-shrink': 0, + }, + 'mjx-dialog > mjx-title > h1': { + 'font-size': '125%', + margin: 0, + }, + 'mjx-dialog > div': { + margin: '0 1em .5em', + padding: '8px 18px', + overflow: 'auto', + border: '2px inset black', + 'background-color': 'white', + 'text-align': 'left', + cursor: 'default', + 'flex-grow': 1, + 'flex-shrink': 1, + }, + 'mjx-dialog > div > pre': { + margin: 0, + }, + + // + // The dialog buttons + // + '.mjx-dialog-button': { + position: 'absolute', + top: '6px', + height: '17px', + width: '17px', + cursor: 'default', + display: 'block', + border: '2px solid #AAA', + 'border-radius': '18px', + 'font-family': '"Courier New", Courier', + 'text-align': 'center', + color: '#F0F0F0', + '-webkit-user-select': 'none', + 'user-select': 'none', + }, + '.mjx-dialog-button:hover': { + color: 'white !important', + border: '2px solid #CCC !important', + }, + '.mjx-dialog-button > mjx-dialog-icon': { + display: 'block', + 'background-color': '#AAA', + border: '1.5px solid', + 'border-radius': '18px', + 'line-height': 0, + padding: '8px 0 6px', + }, + '.mjs-dialog-button > mjx-dialog-icon:hover': { + 'background-color': '#CCC !important', + }, + + // + // The close button + // + 'mjx-dialog-close': { + right: '6px', + 'font-size': '20px;', + }, + + // + // The help button + // + 'mjx-dialog-help': { + left: '6px', + 'font-size': '14px;', + 'font-weight': 'bold', + }, + '.mjx-dialog-help mjx-dialog-help': { + display: 'none', + }, + + // + // Key icons in the dialogs + // + 'mjx-dialog kbd': { + display: 'inline-block', + padding: '3px 5px', + 'font-size': '11px', + 'line-height': '10px', + color: '#444d56', + 'vertical-align': 'middle', + 'background-color': '#fafbfc', + border: 'solid 1.5px #c6cbd1', + 'border-bottom-color': '#959da5', + 'border-radius': '3px', + 'box-shadow': 'inset -.5px -1px 0 #959da5', + }, + + // + // The drag edges and corners + // + 'mjx-dialog-drag[data-drag="top"]': { + height: '5px', + position: 'absolute', + top: '-3px', + left: '10px', + right: '10px', + cursor: 'ns-resize', + }, + 'mjx-dialog-drag[data-drag="bottom"]': { + height: '5px', + position: 'absolute', + bottom: '-3px', + left: '10px', + right: '10px', + cursor: 'ns-resize', + }, + 'mjx-dialog-drag[data-drag="left"]': { + width: '5px', + position: 'absolute', + left: '-3px', + top: '10px', + bottom: '10px', + cursor: 'ew-resize', + }, + 'mjx-dialog-drag[data-drag="right"]': { + width: '5px', + position: 'absolute', + right: '-3px', + top: '10px', + bottom: '10px', + cursor: 'ew-resize', + }, + 'mjx-dialog-drag[data-drag="topleft"]': { + width: '13px', + height: '13px', + position: 'absolute', + left: '-3px', + top: '-3px', + cursor: 'nwse-resize', + }, + 'mjx-dialog-drag[data-drag="topright"]': { + width: '13px', + height: '13px', + position: 'absolute', + right: '-3px', + top: '-3px', + cursor: 'nesw-resize', + }, + 'mjx-dialog-drag[data-drag="botleft"]': { + width: '13px', + height: '13px', + position: 'absolute', + left: '-3px', + bottom: '-3px', + cursor: 'nesw-resize', + }, + 'mjx-dialog-drag[data-drag="botright"]': { + width: '13px', + height: '13px', + position: 'absolute', + right: '-3px', + bottom: '-3px', + cursor: 'nwse-resize', + }, + }; + + protected static helpMessage: string = ` +

The dialog boxes in MathJax are movable and sizeable.

+ +

For mouse users, dragging any of the edges will enlarge or shrink + the dialog box by moving that side. Dragging any of the corners + changes the two sides that meet at that corner. Dragging elsewhere on + the dialog frame will move the dialog without changing its size.

+ +

For keyboard users, there are two ways to adjust the position + and size of the dialog box. The first is to hold the + Alt or Option key and press any of the arrow + keys to move the dialog box in the given direction. Hold the + Win or Command key and press any of the + arrow keys to enlarge or shrink the dialog box. Left and right + move the right-hand edge of the dialog, while up and down move the + bottom edge of the dialog. +

+ +

For some users, holding two keys down at once may be difficult, + so the second way is to press the m to start "move" + mode, then use the arrow keys to move the dialog box in the given + direction. Press m again to stop moving the dialog. + Similarly, press s to start and stop "sizing" mode, + where the arrows will change the size of the dialog box.

+ +

Holding a shift key along with the arrow key will + make larger changes in the size or position, for either method + described above.

+ +

Use Tab to move among the text, buttons, and links + within the dialog. The Enter or Space key + activates the focused item. The Escape key closes the + dialog, as does clicking outside the dialog box, or clicking the + "\u00D7" icon in the upper right-hand corner of the dialog.

+ `; + + /** + * When moving/sizing by keyboard, this gives which is being adjusted. + */ + protected mode: string = ''; + + /** + * @param {DialogArgs} args The data describing the dialog + */ + constructor(args: DialogArgs) { + const { adaptor, node = null } = args; + this.init(adaptor); + + this.node = node; + this.background = isDialog ? null : adaptor.node('mjx-dialog-background'); + + this.x = this.y = 0; + this.dragging = false; + this.action = ''; + + this.dialog = this.html(args); + this.title = this.dialog.firstChild.firstChild.firstChild as HTMLElement; + this.content = this.dialog.firstChild.firstChild.nextSibling as HTMLElement; + const close = this.dialog.lastChild; + close.addEventListener('click', this.closeDialog.bind(this)); + close.addEventListener( + 'keydown', + this.actionKey.bind(this, this.closeDialog.bind(this)) + ); + const help = this.dialog.lastChild.previousSibling; + help.addEventListener('click', this.helpDialog.bind(this, adaptor)); + help.addEventListener( + 'keydown', + this.actionKey.bind(this, this.helpDialog.bind(this, adaptor)) + ); + + this.noDrag = Array.from( + this.dialog.querySelectorAll('[data-drag="none"]') + ); + } + + /** + * Create the stylesheet, if it hasn't already been done + * + * @param {ADAPTOR} adaptor The DOM adaptor to use + */ + protected init(adaptor: ADAPTOR) { + const CLASS = this.constructor as typeof DraggableDialog; + const head = adaptor.document.head; + if (!head.querySelector('#' + CLASS.styleId)) { + const style = adaptor.node('style', { id: CLASS.styleId }); + style.textContent = new StyleJsonSheet(CLASS.styles).cssText; + adaptor.document.head.append(style); + } + } + + /** + * Create the HTML for the dialog layout + * + * @param {DialogArgs} args The data describing the dialog + * @returns {HTMLDialogElement} The dialog node + */ + protected html(args: DialogArgs): HTMLDialogElement { + // + // Deconstruct the data for easier access + // + const { + title, + message, + adaptor, + styles = null, + extraNodes = [], + className = DraggableDialog.className, + } = args; + + // + // Add a styleshee, if needed + // + if (styles) { + const stylesheet = adaptor.node('style'); + stylesheet.textContent = new StyleJsonSheet(styles).cssText; + extraNodes.unshift(stylesheet); + } + // + // Create the dialog HTML tree + // + const label = 'mjx-dialog-label-' + DraggableDialog.id++; + const dialog = adaptor.node( + 'dialog', + { closedby: 'any', class: ('mjx-dialog ' + className).trim() }, + [ + adaptor.node('mjx-dialog', { 'aria-labeledby': label }, [ + adaptor.node('mjx-title', {}, [ + adaptor.node('h1', { id: label, tabIndex: 0 }), + ]), + adaptor.node('div', { 'data-drag': 'none', tabIndex: 0 }), + ]), + ...extraNodes, + adaptor.node('mjx-dialog-spacer', { 'aria-hidden': true }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'top', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'bottom', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'left', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'right', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'topleft', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'topright', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'botleft', + 'aria-hidden': true, + }), + adaptor.node('mjx-dialog-drag', { + 'data-drag': 'botright', + 'aria-hidden': true, + }), + adaptor.node( + 'mjx-dialog-help', + { + class: 'mjx-dialog-button', + 'data-drag': 'none', + tabIndex: 0, + role: 'button', + 'aria-label': 'Dialog Help', + }, + [ + adaptor.node('mjx-dialog-icon', { 'aria-hidden': true }, [ + adaptor.text('?'), + ]), + ] + ), + adaptor.node( + 'mjx-dialog-close', + { + class: 'mjx-dialog-button', + 'data-drag': 'none', + tabIndex: 0, + role: 'button', + 'aria-label': 'Close Dialog Box', + }, + [ + adaptor.node('mjx-dialog-icon', { 'aria-hidden': true }, [ + adaptor.text('\u00d7'), + ]), + ] + ), + ] + ) as HTMLDialogElement; + // + // Set the title and message + // + (dialog.firstChild.firstChild.firstChild as HTMLElement).innerHTML = title; + (dialog.firstChild.childNodes[1] as HTMLElement).innerHTML = message; + return dialog; + } + + /** + * Add the dialog to the page and attach the event handlers + */ + public attach() { + if (isDialog) { + // + // For actual dialog elements, open as a model dialog + // + this.dialog.addEventListener('mousedown', this.MouseDown.bind(this)); + this.dialog.addEventListener('keydown', this.KeyDown.bind(this), true); + document.body.append(this.dialog); + this.dialog.showModal(); + } else { + // + // When a true dialog element isn't available, use the background element + // + this.background.addEventListener('mousedown', this.MouseDown.bind(this)); + this.background.addEventListener( + 'keydown', + this.KeyDown.bind(this), + true + ); + this.dialog.setAttribute('tabindex', '0'); + this.dialog.addEventListener('click', this.stop); + this.background.append(this.dialog); + document.body.append(this.background); + } + context.window.addEventListener( + 'visibilitychange', + this.Visibility.bind(this) + ); + // + // Adjust the min width and height, if the initial dialog is small + // + this.minW = Math.min(this.minW, this.dialog.clientWidth - 8); + this.minH = Math.min( + this.minH, + this.dialog.clientHeight - this.title.offsetHeight - 8 + ); + // + // Focus the title (VoiceOver needs this) + // + this.title.focus(); + } + + /** + * Functions to handle the various mouse events, returning + * an array [dx, dy, dw, dh] of changes to the position and size + * od the dialog. + */ + protected actions: ActionMap = { + // + // Mouse actions + // + + down: { + move: (d) => { + d.dialog.classList.add('mjx-moving'); + }, + }, + + move: { + move: (dg, ev) => [ev.x - dg.x, ev.y - dg.y, 0, 0], + top: (dg, ev) => [0, (ev.y - dg.y) / 2, 0, dg.y - ev.y], + bottom: (dg, ev) => [0, (ev.y - dg.y) / 2, 0, ev.y - dg.y], + left: (dg, ev) => [(ev.x - dg.x) / 2, 0, dg.x - ev.x, 0], + right: (dg, ev) => [(ev.x - dg.x) / 2, 0, ev.x - dg.x, 0], + topleft: (dg, ev) => [ + (ev.x - dg.x) / 2, + (ev.y - dg.y) / 2, + dg.x - ev.x, + dg.y - ev.y, + ], + topright: (dg, ev) => [ + (ev.x - dg.x) / 2, + (ev.y - dg.y) / 2, + ev.x - dg.x, + dg.y - ev.y, + ], + botleft: (dg, ev) => [ + (ev.x - dg.x) / 2, + (ev.y - dg.y) / 2, + dg.x - ev.x, + ev.y - dg.y, + ], + botright: (dg, ev) => [ + (ev.x - dg.x) / 2, + (ev.y - dg.y) / 2, + ev.x - dg.x, + ev.y - dg.y, + ], + }, + + up: { + move: (dg) => { + dg.dialog.classList.remove('mjx-moving'); + }, + }, + + // + // Keyboard actions + // + + keymove: { + left: () => [-5, 0, 0, 0], + right: () => [5, 0, 0, 0], + up: () => [0, -5, 0, 0], + down: () => [0, 5, 0, 0], + }, + + bigmove: { + left: () => [-20, 0, 0, 0], + right: () => [20, 0, 0, 0], + up: () => [0, -20, 0, 0], + down: () => [0, 20, 0, 0], + }, + + keysize: { + left: () => [-3, 0, -6, 0], + right: () => [3, 0, 6, 0], + up: () => [0, -3, 0, -6], + down: () => [0, 3, 0, 6], + }, + + bigsize: { + left: () => [-10, 0, -20, 0], + right: () => [10, 0, 20, 0], + up: () => [0, -10, 0, -20], + down: () => [0, 10, 0, 20], + }, + }; + + /** + * Perform a drag action (resize or move) + * + * @param {string} type The action type to perform + * @param {MouseEvent} event The event causing the action + */ + protected dragAction(type: string, event: MouseEvent = null) { + if (event) { + this.stop(event); + } + // + // Get the move/resize data for the action + // + const action = this.actions[type][this.action]; + const result = action ? action(this, event) : null; + if (!result) { + return; + } + let [dx, dy, dw, dh] = result; + // + // Adjust the width + // + if (dw) { + const W = this.w + dw; + if (W >= this.minW) { + this.x = event?.x; + this.w = W; + this.dialog.style.maxWidth = this.dialog.style.width = W + 'px'; + } else { + dx = 0; + } + } + // + // Adjust the height + // + if (dh) { + const H = this.h + dh; + if (H >= this.minH + this.title.offsetHeight) { + this.y = event?.y; + this.h = H; + this.dialog.style.maxHeight = this.dialog.style.height = H + 'px'; + } else { + dy = 0; + } + } + // + // Adjust the position + // + if (dx || dy) { + if (dx) { + this.x = event?.x; + this.tx += dx || 0; + } + if (dy) { + this.y = event?.y; + this.ty += dy || 0; + } + this.dialog.style.transform = `translate(${this.tx}px, ${this.ty}px)`; + } + } + + /** + * Handle a mousedown event + * + * @param {MouseEvent} event The mouse event to handle + */ + protected MouseDown(event: MouseEvent) { + const target = event.target as HTMLElement; + // + // Check that it is a plain click not on the background + // + if ( + event.buttons !== 1 || + event.shiftKey || + event.metaKey || + event.altKey || + event.ctrlKey + ) { + return; + } + if (!this.inDialog(event)) { + this.closeDialog(event); + return; + } + + // + // Check that it is not on an element marked as not for dragging + // + for (const node of this.noDrag) { + if (target === node || node.contains(target)) { + return; + } + } + // + // Start the drag action + // + this.action = target.getAttribute('data-drag') || 'move'; + this.startDrag(event); + this.dragAction('down', event); + } + + /** + * Handle a mousemove event + * + * @param {MouseEvent} event The mouse event to handle + */ + protected MouseMove(event: MouseEvent) { + if (event.buttons !== 1) { + this.endDrag(); // in case the nouse up occurred over a different element + } + if (this.dragging) { + this.dragAction('move', event); + } + } + + /** + * Handle a mouseup event + * + * @param {MouseEvent} event The mouse event to handle + */ + protected MouseUp(event: MouseEvent) { + if (this.dragging) { + this.dragAction('up', event); + this.endDrag(); + } + } + + /** + * Close the dialog if the pages becomes hidden (e.g., moving + * foreward or backward in the window's history). + */ + protected Visibility() { + if (context.document.hidden) { + this.closeDialog(); + } + } + + /** + * Handle a keydown event + * + * @param {KeyboardEvent} event The key event to handle + */ + protected KeyDown(event: KeyboardEvent) { + const CLASS = this.constructor as typeof DraggableDialog; + const action = CLASS.keyActions.get(event.key); + if (action) { + action(this, event); + } + } + + /** + * Handle the Escape key + * + * @param {KeyboardEvent} event The key event to handle + */ + protected escKey(event: KeyboardEvent) { + this.closeDialog(event); + } + + /** + * Handle the "a" key for selecting all + * + * @param {KeyboardEvent} event The key event to handle + */ + protected aKey(event: KeyboardEvent) { + if (event.ctrlKey || event.metaKey) { + this.selectAll(); + this.stop(event); + } + } + + /** + * Start or stop moving the dialog via arrow keys + * + * @param {KeyboardEvent} event The key event to handle + */ + protected mKey(event: KeyboardEvent) { + this.mode = this.mode === 'move' ? '' : 'move'; + this.stop(event); + } + + /** + * Start or stop sizing the dialog via arrow keys + * + * @param {KeyboardEvent} event The key event to handle + */ + protected sKey(event: KeyboardEvent) { + this.mode = this.mode === 'size' ? '' : 'size'; + this.stop(event); + } + + /** + * Handle the arrow keys + * + * @param {KeyboardEvent} event The key event to handle + * @param {string} direction The direction of the arrow + */ + protected arrowKey(event: KeyboardEvent, direction: string) { + if (event.ctrlKey || this.dragging) return; + this.action = direction; + this.getWH(); + if (event.altKey || this.mode === 'move') { + this.dragAction(event.shiftKey ? 'bigmove' : 'keymove'); + this.stop(event); + } else if (event.metaKey || this.mode === 'size') { + this.dragAction(event.shiftKey ? 'bigsize' : 'keysize'); + this.stop(event); + } + this.action = ''; + } + + /** + * Handle the enter or space key on a button icon + * + * @param {(event: KeyboardEvent) => void} action The action to take on enter or space + * @param {KeyboardEvent} event The event to check + */ + protected actionKey( + action: (event: KeyboardEvent) => void, + event: KeyboardEvent + ) { + if (event.code === 'Enter' || event.code === 'Space') { + action(event); + } + } + + /** + * Select the content of the dialog for copying + */ + protected selectAll() { + const selection = document.getSelection(); + selection.selectAllChildren(this.content); + } + + /** + * Implement the copy-to-clipboard action + */ + public copyToClipboard() { + this.selectAll(); + try { + document.execCommand('copy'); + } catch (err) { + alert(`Can't copy to clipboard: ${err.message}`); + } + document.getSelection().removeAllRanges(); + } + + /** + * Start dragging for a move or resize action. + * + * @param {MouseEvent} event The mousedown event starting the drag + */ + protected startDrag(event: MouseEvent) { + // + // Record the initial data + // + this.x = event.x; + this.y = event.y; + this.getWH(); + this.dragging = true; + // + // Add the mousemove and mouseup handlers + // + const node = this.background || this.dialog; + for (const [name, listener] of this.events) { + node.addEventListener(name, listener); + } + } + + /** + * Cache the current width and height values. + */ + protected getWH() { + this.w = this.dialog.clientWidth - 8; // adjust for the 4px padding on all sides + this.h = this.dialog.clientHeight - 8; + } + + /** + * End a dragging operation + */ + protected endDrag() { + // + // Clear the actions + // + this.action = ''; + this.dragging = false; + // + // Remove the mousemove and mouseup event handlers + // + const node = this.background || this.dialog; + for (const [name, listener] of this.events) { + node.removeEventListener(name, listener); + } + } + + /** + * Close the dialog + * + * @param {Event} event The event that caused the closure + */ + protected closeDialog(event?: Event) { + if (isDialog) { + this.dialog.close(); + this.dialog.remove(); + } else { + this.background.remove(); + } + this.node?.focus(); + if (event) { + this.stop(event); + } + } + + /** + * Display the dialog help message + * + * @param {ADAPTOR} adaptor The DOM adaptor to use + * @param {Event} event The event that triggered the help + */ + protected helpDialog(adaptor: ADAPTOR, event: Event) { + const help = new DraggableDialog({ + title: 'MathJax Dialog Help', + message: (this.constructor as typeof DraggableDialog).helpMessage, + adaptor: adaptor, + className: 'mjx-dialog-help', + styles: { + '.mjx-dialog-help': { + 'max-width': 'calc(min(50em, 80%))', + }, + }, + }); + help.attach(); + this.stop(event); + } + + /** + * Check if an event is inside the dialog. + * + * @param {MouseEvent} event The event to check + * @returns {boolean} True if the event is in the dialog, false if in the background + */ + protected inDialog(event: MouseEvent): boolean { + if (!this.dialog.contains(event.target as HTMLElement)) { + return false; + } + const { x, y } = event; + const { left, right, top, bottom } = this.dialog.getBoundingClientRect(); + return x >= left && x <= right && y >= top && y <= bottom; + } + + /** + * Stop event propagation + * + * @param {Event} event The event that is to be stopped + */ + protected stop(event: Event) { + if (event.preventDefault) { + event.preventDefault(); + } + if (event.stopImmediatePropagation) { + event.stopImmediatePropagation(); + } else if (event.stopPropagation) { + event.stopPropagation(); + } + } +} diff --git a/ts/ui/dialog/InfoDialog.ts b/ts/ui/dialog/InfoDialog.ts new file mode 100644 index 000000000..af44099ca --- /dev/null +++ b/ts/ui/dialog/InfoDialog.ts @@ -0,0 +1,43 @@ +/************************************************************* + * + * Copyright (c) 2025 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. + */ + +/** + * @file Implements the InfoDialog class. + * + * @author dpvc@mathjax.org (Davide Cervone) + */ + +import { DraggableDialog, DialogArgs } from './DraggableDialog.js'; + +export type InfoDialogArgs = DialogArgs; + +/** + * A generic info dialog box + */ +export class InfoDialog extends DraggableDialog { + /** + * Create and display a dialog with the given args + * + * @param {DialogArgs} args The data describing the dialog + * @returns {DraggableDialog} The dialog instance + */ + public static post(args: DialogArgs): DraggableDialog { + const dialog = new this(args); + dialog.attach(); + return dialog; + } +} diff --git a/ts/ui/dialog/SelectionDialog.ts b/ts/ui/dialog/SelectionDialog.ts new file mode 100644 index 000000000..be90fc30d --- /dev/null +++ b/ts/ui/dialog/SelectionDialog.ts @@ -0,0 +1,119 @@ +/************************************************************* + * + * Copyright (c) 2025 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. + */ + +/** + * @file Implements a selection info dialog box + * + * @author dpvc@mathjax.org (Davide P. Cervone) + */ + +import { InfoDialog } from './InfoDialog.js'; +import { MJContextMenu } from '../menu/MJContextMenu.js'; +import { + SelectionOrder, + SelectionGrid, + SelectionBox, +} from '#menu/selection_box.js'; +export { SelectionOrder, SelectionGrid } from '#menu/selection_box.js'; + +export type selection = { title: string; values: string[]; variable: string }; + +/** + * The Selection dialog class + */ +export class SelectionDialog extends SelectionBox { + /** + * @override + */ + constructor( + title: string, + signature: string, + selections: selection[], + order: SelectionOrder, + grid: SelectionGrid, + menu: MJContextMenu + ) { + super(title, signature, order, grid); + this.attachMenu(menu); + const factory = menu.factory; + this.selections = selections.map((x) => + factory.get('selectionMenu')(factory, x, this) + ); + } + + /** + * @override + */ + public post() { + // + // Get the active output jax (to get its adaptor) + // + const jax = Array.from(Object.values((this.menu as any).jax)).filter( + (j) => !!j + )[0] as any; + // + // Use an InfoDialog rather than the mj-context-menu Info object + // + const dialog = new InfoDialog({ + title: (this as any).title, // should be protected rather than private + message: '', + adaptor: jax.adaptor, + styles: { + 'mjx-dialog > div': { + padding: 0, + }, + }, + }); + dialog.attach(); + // + // Use the SelectionBox display function + // + this.contentDiv = (dialog as any).content as HTMLElement; + this.display(); + } + + /** + * @override + */ + public display() { + // + // This is the same as teh SelectionBox() function, but without the super.display() call. + // + const THIS = this as any; // the methods below are private, so work around that. + THIS.order(); + if (!this.selections.length) { + return; + } + const outerDivs: HTMLElement[] = []; + let maxWidth = 0; + let balancedColumn: number[] = []; + const chunks = THIS.getChunkSize(this.selections.length); + for (let i = 0; i < this.selections.length; i += chunks) { + const sels = this.selections.slice(i, i + chunks); + const [div, width, height, column] = THIS.rowDiv(sels); + outerDivs.push(div); + maxWidth = Math.max(maxWidth, width); + sels.forEach((sel) => (sel.html.style.height = height + 'px')); + balancedColumn = THIS.combineColumn(balancedColumn, column); + } + if (THIS._balanced) { + THIS.balanceColumn(outerDivs, balancedColumn); + maxWidth = balancedColumn.reduce((x, y) => x + y - 2, 20); // remove 2px for borders + } + outerDivs.forEach((div) => (div.style.width = maxWidth + 'px')); + } +} diff --git a/ts/ui/menu/AnnotationMenu.ts b/ts/ui/menu/AnnotationMenu.ts index d4ff01e63..d7a85e0bd 100644 --- a/ts/ui/menu/AnnotationMenu.ts +++ b/ts/ui/menu/AnnotationMenu.ts @@ -23,9 +23,12 @@ */ import { SubMenu, Submenu } from './mj-context-menu.js'; -import { MJContextMenu } from './MJContextMenu.js'; +import { + MJContextMenu, + DynamicSubmenu, + SubmenuCallback, +} from './MJContextMenu.js'; import { MmlNode } from '../../core/MmlTree/MmlNode.js'; -import { SelectableInfo } from './SelectableInfo.js'; import * as MenuUtil from './MenuUtil.js'; /** @@ -39,22 +42,20 @@ type AnnotationTypes = { [type: string]: string[] }; /** * Returns a method to create the dynamic submenu for showing annotations. * - * @param {SelectableInfo} box The info box in which to post annotation info. + * @param {() => void} box The info box in which to post annotation info. * @param {AnnotationTypes} types The legitimate annotation types. * @param {[string, string][]} cache We cache annotations of a math item, so we * only have to compute them once for the two annotation menus. - * @returns {(menu: MJContextMenu, sub: Submenu) => SubMenu} Method generating - * the show annotations submenu. + * @returns {DynamicSubmenu} Method generating the show annotations submenu. */ export function showAnnotations( - box: SelectableInfo, + box: () => void, types: AnnotationTypes, cache: [string, string][] -): (menu: MJContextMenu, sub: Submenu) => SubMenu { - return (menu: MJContextMenu, sub: Submenu) => { +): DynamicSubmenu { + return (menu: MJContextMenu, sub: Submenu, callback: SubmenuCallback) => { getAnnotation(getSemanticNode(menu), types, cache); - box.attachMenu(menu); - return createAnnotationMenu(menu, sub, cache, () => box.post()); + callback(createAnnotationMenu(menu, sub, cache, box)); }; } @@ -63,15 +64,17 @@ export function showAnnotations( * Clears the annotation cache parameter. * * @param {[string, string][]} cache The annotation cache. - * @returns {(menu: MJContextMenu, sub: Submenu) => SubMenu} Method generating + * @returns {DynamicSubmenu} Method generating * the copy annotations submenu. */ -export function copyAnnotations(cache: [string, string][]) { - return (menu: MJContextMenu, sub: Submenu) => { +export function copyAnnotations(cache: [string, string][]): DynamicSubmenu { + return (menu: MJContextMenu, sub: Submenu, callback: SubmenuCallback) => { const annotations = cache.slice(); cache.length = 0; - return createAnnotationMenu(menu, sub, annotations, () => - MenuUtil.copyToClipboard(annotation.trim()) + callback( + createAnnotationMenu(menu, sub, annotations, () => + MenuUtil.copyToClipboard(annotation.trim()) + ) ); }; } diff --git a/ts/ui/menu/MJContextMenu.ts b/ts/ui/menu/MJContextMenu.ts index c4554d287..42441f52a 100644 --- a/ts/ui/menu/MJContextMenu.ts +++ b/ts/ui/menu/MJContextMenu.ts @@ -34,6 +34,14 @@ import { Item, } from './mj-context-menu.js'; +export type SubmenuCallback = (sub: SubMenu) => void; + +export type DynamicSubmenu = ( + menu: MJContextMenu, + sub: Submenu, + callback: SubmenuCallback +) => void; + /*==========================================================================*/ /** @@ -44,19 +52,10 @@ export class MJContextMenu extends ContextMenu { /** * Static map to hold methods for re-computing dynamic submenus. * - * @type {Map Submenu>} + * @type {Map} */ - public static DynamicSubmenus: Map< - string, - [ - ( - menu: MJContextMenu, - sub: Submenu, - callback: (sub: SubMenu) => void - ) => void, - string, - ] - > = new Map(); + public static DynamicSubmenus: Map = + new Map(); /** * The MathItem that has posted the menu @@ -116,7 +115,9 @@ export class MJContextMenu extends ContextMenu { * @override */ public unpost() { - super.unpost(); + if ((this as any).posted) { + super.unpost(); + } if (this.mathItem) { this.mathItem.outputData.nofocus = this.nofocus; } diff --git a/ts/ui/menu/Menu.ts b/ts/ui/menu/Menu.ts index f12e81fa0..2f91ca01f 100644 --- a/ts/ui/menu/Menu.ts +++ b/ts/ui/menu/Menu.ts @@ -35,6 +35,8 @@ import { expandable, } from '../../util/Options.js'; import { ExplorerMathItem } from '../../a11y/explorer.js'; +import { InfoDialog } from '../dialog/InfoDialog.js'; +import { CopyDialog } from '../dialog/CopyDialog.js'; import { SVG } from '../../output/svg.js'; @@ -42,11 +44,10 @@ import * as AnnotationMenu from './AnnotationMenu.js'; import { MJContextMenu } from './MJContextMenu.js'; import { RadioCompare } from './RadioCompare.js'; import { MmlVisitor } from './MmlVisitor.js'; -import { SelectableInfo } from './SelectableInfo.js'; import { MenuMathDocument } from './MenuHandler.js'; import * as MenuUtil from './MenuUtil.js'; -import { Info, Parser, Rule, CssStyles, Submenu } from './mj-context-menu.js'; +import { Parser, Rule, CssStyles, Submenu } from './mj-context-menu.js'; /*==========================================================================*/ @@ -140,7 +141,7 @@ export class Menu { zoom: 'NoZoom', zscale: '200%', renderer: 'CHTML', - alt: false, + alt: true, cmd: false, ctrl: false, shift: false, @@ -184,7 +185,7 @@ export class Menu { '.mjx-dashed{stroke-dasharray:140}', '.mjx-dotted{stroke-linecap:round;stroke-dasharray:0,140}', 'use[data-c]{stroke-width:3px}', - ].join(''); + ].join('\n'); /** * The number of startup modules that are currently being loaded @@ -291,27 +292,99 @@ export class Menu { /** * The "About MathJax" info box */ - protected about = new Info( - 'MathJax v' + mathjax.version, - () => { - const lines = [] as string[]; - lines.push( - 'Input Jax: ' + this.document.inputJax.map((jax) => jax.name).join(', ') - ); - lines.push('Output Jax: ' + this.document.outputJax.name); - lines.push('Document Type: ' + this.document.kind); - return lines.join('
'); - }, - 'www.mathjax.org' - ); + protected about() { + const lines = [] as string[]; + // + // Add the input and output jax and the document type + // + lines.push( + 'Input Jax: ' + this.document.inputJax.map((jax) => jax.name).join(', ') + ); + lines.push('Output Jax: ' + this.document.outputJax.name); + lines.push('Document Type: ' + this.document.kind); + // + // Add the loaded packages and their versions + // + if (MathJax && MathJax.loader) { + lines.push('
Modules Loaded:'); + const Package = MathJax._.components.package.Package; + const versions = (MathJax as any).loader.versions; + for (const name of Array.from(Package.packages.keys()).sort( + this.sortPackages + )) { + const version = versions.get(Package.resolvePath(name)); + if (version) { + lines.push( + `    ${name} (${version})` + ); + } + } + } + // + // Post the dialog + // + InfoDialog.post({ + title: 'MathJax v' + mathjax.version + '', + message: lines.join('
'), + adaptor: this.document.adaptor, + styles: { + '.mjx-dialog': { + 'max-height': 'calc(min(20em, 85%))', + }, + 'mjx-dialog > div': { + 'white-space': 'nowrap', + }, + 'dialog.mjx-dialog-help > mjx-dialog > div': { + 'white-space': 'normal', + }, + 'mjx-v': { + 'font-size': '80%', + }, + }, + extraNodes: [ + this.document.adaptor.node( + 'a', + { href: 'https://www.mathjax.org', 'data-drag': 'false' }, + [this.document.adaptor.text('https://www.mathjax.org')] + ), + ], + }); + } + + /** + * Function to sort the package names + * + * @param {string} a The first module name + * @param {string} b The second module name + * @returns {number} -1 of a < b, 1 if a > b + */ + protected sortPackages(a: string, b: string): number { + const [prefixA, rootA] = a.includes('/') ? a.split(/\//) : ['', a]; + const [prefixB, rootB] = b.includes('/') ? b.split(/\//) : ['', b]; + return prefixA === prefixB + ? rootA < rootB + ? -1 + : 1 + : prefixA.charAt(0) === '[' + ? prefixB.charAt(0) === '[' + ? prefixA < prefixB + ? -1 + : 1 + : 1 + : prefixB.charAt(0) === '[' + ? -1 + : prefixA < prefixB + ? -1 + : 1; + } /** * The "MathJax Help" info box */ - protected help = new Info( - 'MathJax Help', - () => { - return [ + protected help() { + InfoDialog.post({ + title: 'MathJax Help', + message: [ '

MathJax is a JavaScript library that allows page', ' authors to include mathematics within their web pages.', " As a reader, you don't need to do anything to make that happen.

", @@ -344,146 +417,125 @@ export class Menu { ' to save the preferences set via this menu locally in your browser. These', ' are not used to track you, and are not transferred or used remotely by', ' MathJax in any way.

', - ].join('\n'); - }, - 'www.mathjax.org' - ); + ].join('\n'), + adaptor: this.document.adaptor, + extraNodes: [ + this.document.adaptor.node( + 'a', + { href: 'https://www.mathjax.org', 'data-drag': 'none' }, + [this.document.adaptor.text('https://www.mathjax.org')] + ), + ], + }); + } /** * The "Show As MathML" info box */ - protected mathmlCode = new SelectableInfo( - 'MathJax MathML Expression', - () => { - if (!this.menu.mathItem) return ''; - const text = this.toMML(this.menu.mathItem); - return '
' + this.formatSource(text) + '
'; - }, - '' - ); + protected mathMLCode() { + CopyDialog.post({ + title: 'MathJax MathML Expression', + message: this.menu.mathItem ? this.toMML(this.menu.mathItem) : '', + adaptor: this.document.adaptor, + code: true, + }); + } /** * The "Show As (original form)" info box */ - protected originalText = new SelectableInfo( - 'MathJax Original Source', - () => { - if (!this.menu.mathItem) return ''; - const text = this.menu.mathItem.math; - return ( - '
' +
-        this.formatSource(text) +
-        '
' - ); - }, - '' - ); + protected originalText() { + CopyDialog.post({ + title: 'MathJax Original Source', + message: this.menu.mathItem?.math ?? '', + adaptor: this.document.adaptor, + code: true, + }); + } /** * The "Show As Annotation" info box */ - protected annotationBox = new SelectableInfo( - 'MathJax Annotation Text', - () => { - const text = AnnotationMenu.annotation; - return ( - '
' +
-        this.formatSource(text) +
-        '
' - ); - }, - '' - ); + protected annotationBox() { + CopyDialog.post({ + title: 'MathJax Annotation Text', + message: AnnotationMenu.annotation, + adaptor: this.document.adaptor, + code: true, + }); + } /** * The "Show As SVG Image" info box */ - protected svgImage = new SelectableInfo( - 'MathJax SVG Image', - () => { - // - // SVG image inserted after it is created - // - return ( - '
' + - 'Generative SVG Image...
' - ); - }, - '' - ); + public async svgImage() { + CopyDialog.post({ + title: 'MathJax SVG Image', + message: await this.toSVG(this.menu.mathItem), + adaptor: this.document.adaptor, + code: true, + }); + } /** * The "Show As Speech Text" info box */ - protected speechText = new SelectableInfo( - 'MathJax Speech Text', - () => { - if (!this.menu.mathItem) return ''; - return ( - '
' + - this.formatSource(this.menu.mathItem.outputData.speech) + - '
' - ); - }, - '' - ); + protected speechText() { + CopyDialog.post({ + title: 'MathJax Speech Text', + message: this.menu.mathItem?.outputData?.speech ?? '', + adaptor: this.document.adaptor, + code: true, + }); + } /** * The "Show As Speech Text" info box */ - protected brailleText = new SelectableInfo( - 'MathJax Braille Code', - () => { - if (!this.menu.mathItem) return ''; - return ( - '
' + - this.formatSource(this.menu.mathItem.outputData.braille) + - '
' - ); - }, - '' - ); + protected brailleText() { + CopyDialog.post({ + title: 'MathJax Braille Text', + message: this.menu.mathItem?.outputData?.braille ?? '', + adaptor: this.document.adaptor, + code: true, + }); + } /** * The "Show As Error Message" info box */ - protected errorMessage = new SelectableInfo( - 'MathJax Error Message', - () => { - if (!this.menu.mathItem) return ''; - return ( - '
' +
-        this.formatSource(this.menu.errorMsg) +
-        '
' - ); - }, - '' - ); + protected errorMessage() { + CopyDialog.post({ + title: 'MathJax Error Message', + message: this.menu.mathItem ? this.menu.errorMsg : '', + adaptor: this.document.adaptor, + code: true, + }); + } /** * The info box for zoomed expressions */ - protected zoomBox = new Info( - 'MathJax Zoomed Expression', - () => { - if (!this.menu.mathItem) return ''; - const element = (this.menu.mathItem.typesetRoot as any).cloneNode( - true - ) as HTMLElement; - element.style.margin = '0'; - const scale = 1.25 * parseFloat(this.settings.zscale); // 1.25 is to reverse the default 80% font-size - return ( - '
' + element.outerHTML + '
' - ); - }, - '' - ); - - protected postInfo(dialog: Info) { + protected zoomBox() { + let text = ''; if (this.menu.mathItem) { - this.menu.nofocus = !!this.menu.mathItem.outputData.nofocus; + const node = this.menu.mathItem.typesetRoot as HTMLElement; + const size = this.document.adaptor.fontSize(node); + const zoom = node.cloneNode(true) as HTMLElement; + zoom.style.margin = '0'; + const scale = (size * parseFloat(this.settings.zscale)) / 100; + text = `
${zoom.outerHTML}
`; } - dialog.post(); + InfoDialog.post({ + title: 'MathJax Zoomed Expression', + message: text, + adaptor: this.document.adaptor, + styles: { + 'mjx-dialog > div': { + padding: '1.8em', + }, + }, + }); } /*======================================================================*/ @@ -607,38 +659,23 @@ export class Menu { ], items: [ this.submenu('Show', 'Show Math As', [ - this.command('MathMLcode', 'MathML Code', () => - this.postInfo(this.mathmlCode) - ), - this.command('Original', 'Original Form', () => - this.postInfo(this.originalText) - ), + this.command('MathMLcode', 'MathML Code', () => this.mathMLCode()), + this.command('Original', 'Original Form', () => this.originalText()), this.rule(), - this.command( - 'Speech', - 'Speech Text', - () => this.postInfo(this.speechText), - { - disabled: true, - } - ), - this.command( - 'Braille', - 'Braille Code', - () => this.postInfo(this.brailleText), - { disabled: true } - ), - this.command('SVG', 'SVG Image', () => this.postSvgImage(), { + this.command('Speech', 'Speech Text', () => this.speechText(), { + disabled: true, + }), + this.command('Braille', 'Braille Code', () => this.brailleText(), { + disabled: true, + }), + this.command('SVG', 'SVG Image', () => this.svgImage(), { disabled: true, }), this.submenu('ShowAnnotation', 'Annotation'), this.rule(), - this.command( - 'Error', - 'Error Message', - () => this.postInfo(this.errorMessage), - { disabled: true } - ), + this.command('Error', 'Error Message', () => this.errorMessage(), { + disabled: true, + }), ]), this.submenu('Copy', 'Copy to Clipboard', [ this.command('MathMLcode', 'MathML Code', () => this.copyMathML()), @@ -694,9 +731,7 @@ export class Menu { this.submenu('Language', 'Language'), this.rule(), this.submenu('ZoomTrigger', 'Zoom Trigger', [ - this.command('ZoomNow', 'Zoom Once Now', () => - this.zoom(null, '', this.menu.mathItem) - ), + this.command('ZoomNow', 'Zoom Once Now', () => this.zoom(null, '')), this.rule(), this.radioGroup('zoom', [ ['Click'], @@ -864,8 +899,8 @@ export class Menu { ), ]), this.rule(), - this.command('About', 'About MathJax', () => this.postInfo(this.about)), - this.command('Help', 'MathJax Help', () => this.postInfo(this.help)), + this.command('About', 'About MathJax', () => this.about()), + this.command('Help', 'MathJax Help', () => this.help()), ], }) as MJContextMenu; const menu = this.menu; @@ -873,12 +908,11 @@ export class Menu { menu.findID('Settings', 'Overflow', 'Elide').disable(); menu.findID('Braille', 'ueb').hide(); menu.setJax(this.jax); - this.attachDialogMenus(menu); this.checkLoadableItems(); const cache: [string, string][] = []; MJContextMenu.DynamicSubmenus.set('ShowAnnotation', [ AnnotationMenu.showAnnotations( - this.annotationBox, + () => this.annotationBox(), this.options.annotationTypes, cache ), @@ -892,22 +926,6 @@ export class Menu { CssStyles.addMenuStyles(this.document.document as any); } - /** - * @param {MJContextMenu} menu The menu to attach - */ - protected attachDialogMenus(menu: MJContextMenu) { - this.about.attachMenu(menu); - this.help.attachMenu(menu); - this.originalText.attachMenu(menu); - this.mathmlCode.attachMenu(menu); - this.originalText.attachMenu(menu); - this.svgImage.attachMenu(menu); - this.speechText.attachMenu(menu); - this.brailleText.attachMenu(menu); - this.errorMessage.attachMenu(menu); - this.zoomBox.attachMenu(menu); - } - /** * Check whether the startup and loader modules are available, and * if not, disable the a11y modules (since we can't load them @@ -1475,18 +1493,6 @@ export class Menu { } } - /** - * @param {string} text The text to be displayed in an Info box - * @returns {string} The text with HTML specials being escaped - */ - protected formatSource(text: string): string { - return text - .trim() - .replace(/&/g, '&') - .replace(//g, '>'); - } - /** * @param {HTMLMATHITEM} math The MathItem to serialize as MathML * @returns {string} The serialized version of the internal MathML @@ -1504,12 +1510,11 @@ export class Menu { * @param {HTMLMATHITEM} math The MathItem to serialize as SVG * @returns {Promise} A promise returning the serialized SVG */ - protected toSVG(math: HTMLMATHITEM): Promise { + protected async toSVG(math: HTMLMATHITEM): Promise { const jax = this.jax.SVG; - if (!jax) - return Promise.resolve( - "SVG can't be produced.
Try switching to SVG output first." - ); + if (!jax) { + return "SVG can't be produced.
Try switching to SVG output first."; + } const adaptor = jax.adaptor; const cache = jax.options.fontCache; const breaks = !!math.root.getProperty('process-breaks'); @@ -1520,9 +1525,7 @@ export class Menu { ) { for (const child of adaptor.childNodes(math.typesetRoot)) { if (adaptor.kind(child) === 'svg') { - return Promise.resolve( - this.formatSvg(adaptor.serializeXML(child as HTMLElement)) - ); + return this.formatSvg(adaptor.serializeXML(child as HTMLElement)); } } } @@ -1552,14 +1555,12 @@ export class Menu { jax.unmarkInlineBreaks(math.root); math.root.setProperty('inlineMarked', false); } - const promise = mathjax.handleRetriesFor(() => { + await mathjax.handleRetriesFor(() => { jax.toDOM(math, div, jax.document); }); - return promise.then(() => { - math.root = root; - jax.options.fontCache = cache; - return this.formatSvg(jax.adaptor.innerHTML(div)); - }); + math.root = root; + jax.options.fontCache = cache; + return this.formatSvg(jax.adaptor.serializeXML(div)); } /** @@ -1567,18 +1568,47 @@ export class Menu { * @returns {string} The adjusted SVG string */ protected formatSvg(svg: string): string { + // + // Insert the minimal CSS styles + // const css = (this.constructor as typeof Menu).SvgCss; svg = svg.match(/^/) ? svg.replace(//, ``) : svg.replace(/^()/, `$1`); + // + // Use black as default color + // svg = svg .replace(/ (?:role|focusable)=".*?"/g, '') .replace(/"currentColor"/g, '"black"'); + // + // Add newlines and indentation + // + const SVG = svg.split(/(<\/?[a-zA-Z].*?>)/); + for (let i = 2, spaces = ''; i < SVG.length; i += 2) { + const prev = SVG[i - 1]; + const next = SVG[i + 1]; + if (prev.charAt(1) !== '/' && prev.charAt(prev.length - 2) !== '/') { + spaces += ' '; + } + if (next) { + if (next.charAt(1) === '/') spaces = spaces.slice(2); + SVG[i + 1] = next.replace( + ' xmlns:xlink="http://www.w3.org/1999/xlink"', + '' + ); + } + if (SVG[i]) { + SVG[i] = '\n ' + spaces + SVG[i].replace(/\n/g, '\n ' + spaces); + } + SVG[i] += '\n' + spaces; + } + svg = SVG.join(''); + // + // Remove unwanted attributes + // if (!this.settings.showSRE) { - svg = svg.replace( - / (?:data-semantic-.*?|data-speech-node|role|aria-(?:level|posinset|setsize|owns))=".*?"/g, - '' - ); + svg = svg.replace(/ (?:data-semantic-.*?|data-speech-node)=".*?"/g, ''); } if (!this.settings.showTex) { svg = svg.replace(/ data-latex(?:-item)?=".*?"/g, ''); @@ -1591,39 +1621,21 @@ export class Menu { ) .replace(/ data-mml-node="TeXAtom"/g, ''); } + // + // Return the result + // return `${XMLDECLARATION}\n${svg}`; } - /** - * Get the SVG image and post it - */ - public postSvgImage() { - this.postInfo(this.svgImage); - this.toSVG(this.menu.mathItem).then((svg) => { - const html = this.svgImage.html.querySelector('#svg-image'); - html.innerHTML = this.formatSource(svg).replace(/\n/g, '
'); - }); - } - /*======================================================================*/ /** * @param {MouseEvent|null} event The event triggering the zoom (or null for from a menu pick) * @param {string} type The type of event occurring (click, dblclick) - * @param {HTMLMATHITEM} math The MathItem triggering the event */ - protected zoom(event: MouseEvent, type: string, math: HTMLMATHITEM) { + protected zoom(event: MouseEvent, type: string) { if (!event || this.isZoomEvent(event, type)) { - this.menu.mathItem = math; - if (event) { - // - // The zoomBox.post() below assumes the menu is open, - // so if this zoom() call is from an event (not the menu), - // make sure the menu is open before posting the zoom box - // - this.menu.post(event); - } - this.postInfo(this.zoomBox); + this.zoomBox(); } } @@ -1717,6 +1729,9 @@ export class Menu { math.typesetRoot.tabIndex = this.settings.inTabOrder ? 0 : -1; } + /** + * @param {HTMLMATHITEM} math The math item to which listeners are to be attached + */ public addEvents(math: HTMLMATHITEM) { const node = math.typesetRoot; node.addEventListener( @@ -1740,14 +1755,10 @@ export class Menu { true ); node.addEventListener('keydown', () => (this.menu.mathItem = math), true); - node.addEventListener( - 'click', - (event) => this.zoom(event, 'Click', math), - true - ); + node.addEventListener('click', (event) => this.zoom(event, 'Click'), true); node.addEventListener( 'dblclick', - (event) => this.zoom(event, 'DoubleClick', math), + (event) => this.zoom(event, 'DoubleClick'), true ); } diff --git a/ts/ui/menu/MenuHandler.ts b/ts/ui/menu/MenuHandler.ts index bf5498d93..1dfdb4e70 100644 --- a/ts/ui/menu/MenuHandler.ts +++ b/ts/ui/menu/MenuHandler.ts @@ -201,6 +201,7 @@ export function MenuMathDocumentMixin( enableSpeech: true, enableBraille: true, enableExplorer: true, + enableExplorerHelp: true, enrichSpeech: 'none', enrichError: (_doc: MenuMathDocument, _math: MenuMathItem, err: Error) => console.warn('Enrichment Error:', err), diff --git a/ts/ui/menu/SelectableInfo.ts b/ts/ui/menu/SelectableInfo.ts deleted file mode 100644 index 34a4127e1..000000000 --- a/ts/ui/menu/SelectableInfo.ts +++ /dev/null @@ -1,85 +0,0 @@ -/************************************************************* - * - * Copyright (c) 2019-2025 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. - */ - -/** - * @file An info box that allows text selection and has copy-to-clipboard functions - * - * @author dpvc@mathjax.org (Davide Cervone) - */ - -import { Info, HtmlClasses } from './mj-context-menu.js'; - -/*==========================================================================*/ - -/** - * The SelectableInfo class definition - */ -export class SelectableInfo extends Info { - /** - * Handle "select all" so that only the info-box's text is selected - * (not the whole page) - * - * @override - */ - public keydown(event: KeyboardEvent) { - if (event.key === 'a' && (event.ctrlKey || event.metaKey)) { - this.selectAll(); - this.stop(event); - return; - } - super.keydown(event); - } - - /** - * Select all the main text of the info box - */ - public selectAll() { - const selection = document.getSelection(); - selection.selectAllChildren( - this.html.querySelector('.CtxtMenu_InfoContent').firstChild - ); - } - - /** - * Implement the copy-to-clipboard action - */ - public copyToClipboard() { - this.selectAll(); - try { - document.execCommand('copy'); - } catch (err) { - alert(`Can't copy to clipboard: ${err.message}`); - } - document.getSelection().removeAllRanges(); - } - - /** - * Attach the copy-to-clipboard action to its button - */ - public generateHtml() { - super.generateHtml(); - const footer = this.html.querySelector( - 'span.' + HtmlClasses['INFOSIGNATURE'] - ); - const button = footer.appendChild(document.createElement('input')); - button.type = 'button'; - button.value = 'Copy to Clipboard'; - button.addEventListener('click', (_event: MouseEvent) => - this.copyToClipboard() - ); - } -} diff --git a/ts/ui/menu/mj-context-menu.ts b/ts/ui/menu/mj-context-menu.ts index 7ddaf2b89..0a69005d5 100644 --- a/ts/ui/menu/mj-context-menu.ts +++ b/ts/ui/menu/mj-context-menu.ts @@ -25,7 +25,6 @@ export { ContextMenu } from '#menu/context_menu.js'; export { SubMenu } from '#menu/sub_menu.js'; export { Submenu } from '#menu/item_submenu.js'; -export { Info } from '#menu/info.js'; export { Radio } from '#menu/item_radio.js'; export { Rule } from '#menu/item_rule.js'; export { ParserFactory } from '#menu/parser_factory.js';