From dcc8bd499b07cbdf3d4550641d7122e0419898bc Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 25 Sep 2025 05:08:33 -0400 Subject: [PATCH 01/17] Make dialogs movable and sizable. --- components/mjs/core/config.json | 2 + ts/a11y/explorer.ts | 74 +-- ts/a11y/explorer/KeyExplorer.ts | 299 ++++++------ ts/a11y/speech/SpeechMenu.ts | 34 +- ts/ui/dialog/CopyDialog.ts | 77 +++ ts/ui/dialog/DraggableDialog.ts | 838 ++++++++++++++++++++++++++++++++ ts/ui/dialog/InfoDialog.ts | 43 ++ ts/ui/dialog/SelectionDialog.ts | 119 +++++ ts/ui/menu/AnnotationMenu.ts | 29 +- ts/ui/menu/MJContextMenu.ts | 27 +- ts/ui/menu/Menu.ts | 480 +++++++++--------- ts/ui/menu/SelectableInfo.ts | 85 ---- ts/ui/menu/mj-context-menu.ts | 1 - 13 files changed, 1533 insertions(+), 575 deletions(-) create mode 100644 ts/ui/dialog/CopyDialog.ts create mode 100644 ts/ui/dialog/DraggableDialog.ts create mode 100644 ts/ui/dialog/InfoDialog.ts create mode 100644 ts/ui/dialog/SelectionDialog.ts delete mode 100644 ts/ui/menu/SelectableInfo.ts 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..34af8d182 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -417,78 +417,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 +484,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..af0be2b07 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,160 @@ 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.

-

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 */ @@ -933,40 +947,49 @@ 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 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-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(); } /********************************************************************/ 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..b90b0926d --- /dev/null +++ b/ts/ui/dialog/DraggableDialog.ts @@ -0,0 +1,838 @@ +/************************************************************* + * + * 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'; + +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 +}; + +/** + * True if we can rely on an HTML dialog element. + */ +export const isDialog: boolean = !!window.HTMLDialogElement; + +/*========================================================================*/ + +/** + * The draggable dialog class + */ +export abstract 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)], + ]; + + /** + * 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 = ''; + + /** + * 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%', + height: '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 close button + // + 'mjx-dialog-close': { + position: 'absolute', + right: '6px', + top: '6px', + cursor: 'default', + display: 'block', + border: '2px solid #AAA', + 'border-radius': '18px', + 'font-family': '"Courier New", Courier', + 'font-size': '24px;', + color: '#F0F0F0', + '-webkit-user-select': 'none', + 'user-select': 'none', + }, + 'mjx-dialog-close:hover': { + color: 'white !important', + border: '2px solid #CCC !important', + }, + 'mjx-dialog-close-x': { + display: 'block', + 'background-color': '#AAA', + border: '1.5px solid', + 'border-radius': '18px', + 'line-height': 0, + padding: '8px 0 6px', + }, + 'mjx-dialog-close-x:hover': { + 'background-color': '#CCC !important', + }, + + // + // 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', + }, + }; + + /** + * @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.closeKey.bind(this)); + + 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 doalog HTML tree + // + const dialog = adaptor.node( + 'dialog', + { closedby: 'any', class: ('mjx-dialog ' + className).trim() }, + [ + adaptor.node('mjx-dialog', { 'aria-labeledby': 'mjx-dialog-label' }, [ + adaptor.node('mjx-title', {}, [ + adaptor.node('h1', { id: 'mjx-dialog-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-close', + { + 'data-drag': 'none', + tabIndex: 0, + role: 'button', + 'aria-label': 'Close Dialog Box', + }, + [ + adaptor.node('mjx-dialog-close-x', { '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); + } + // + // 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 = { + 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'); + }, + }, + }; + + /** + * 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) { + 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(); + } + } + + /** + * Handle a keydown event + * + * @param {KeyboardEvent} event The key event to handle + */ + protected KeyDown(event: KeyboardEvent) { + if (event.code === 'Escape') { + this.closeDialog(event); + return; + } + if (event.key === 'a' && (event.ctrlKey || event.metaKey)) { + this.selectAll(); + this.stop(event); + return; + } + } + + /** + * Handle the enter or space key on the close icon + * + * @param {KeyboardEvent} event The event to check + */ + protected closeKey(event: KeyboardEvent) { + if (event.code === 'Enter' || event.code === 'Space') { + this.closeDialog(event); + } + } + + /** + * Select the contents 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.w = this.dialog.clientWidth - 8; // adjust for the 4px padding on all sides + this.h = this.dialog.clientHeight - 8; + 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); + } + } + + /** + * 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(); + 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..a1001d941 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'; /** @@ -43,18 +46,16 @@ type AnnotationTypes = { [type: string]: string[] }; * @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) => { + 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..0ede2f1eb 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,96 @@ 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', + }, + '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 +414,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 +656,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 +728,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 +896,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 +905,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 +923,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 +1490,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 +1507,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 +1522,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 +1552,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 +1565,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 +1618,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 +1726,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 +1752,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/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'; From a88d24943e4a5f5258984079e322ff2859cd26ab Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 25 Sep 2025 06:25:19 -0400 Subject: [PATCH 02/17] Fix issue with tests --- ts/ui/dialog/DraggableDialog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index b90b0926d..4550e1297 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -23,6 +23,7 @@ import { DOMAdaptor } from '../../core/DOMAdaptor.js'; import { StyleJson, StyleJsonSheet } from '../../util/StyleJson.js'; +import { context } from '../../util/context.js'; export type ADAPTOR = DOMAdaptor; @@ -49,7 +50,7 @@ export type DialogArgs = { /** * True if we can rely on an HTML dialog element. */ -export const isDialog: boolean = !!window.HTMLDialogElement; +export const isDialog: boolean = !!context.window?.HTMLDialogElement; /*========================================================================*/ From 9ea36070824de52864f277285e9fbde0f04295bb Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 25 Sep 2025 16:10:50 -0400 Subject: [PATCH 03/17] Add keyboard control for sizing and moving, and a help button to explain how it works. --- ts/a11y/explorer/KeyExplorer.ts | 13 -- ts/ui/dialog/DraggableDialog.ts | 268 ++++++++++++++++++++++++++++---- 2 files changed, 239 insertions(+), 42 deletions(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index af0be2b07..4aa0e2652 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -969,19 +969,6 @@ export class SpeechExplorer 'font-size': '20px', margin: '.5em 0', }, - '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', - }, 'mjx-dialog ul': { 'list-style-type': 'none', }, diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index 4550e1297..320679f6b 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -47,6 +47,14 @@ export type DialogArgs = { 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. */ @@ -57,7 +65,7 @@ export const isDialog: boolean = !!context.window?.HTMLDialogElement; /** * The draggable dialog class */ -export abstract class DraggableDialog { +export class DraggableDialog { /** * The minimum width of the dialog */ @@ -135,6 +143,18 @@ export abstract class DraggableDialog { ['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)], + ['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 */ @@ -144,6 +164,11 @@ export abstract class DraggableDialog { */ public static className: string = ''; + /** + * An id incremented for each instance of a dialog + */ + public static id: number = 0; + /** * The default styles for all dialogs */ @@ -243,27 +268,27 @@ export abstract class DraggableDialog { }, // - // The close button + // The dialog buttons // - 'mjx-dialog-close': { + '.mjx-dialog-button': { position: 'absolute', - right: '6px', top: '6px', + height: '17px', cursor: 'default', display: 'block', border: '2px solid #AAA', 'border-radius': '18px', 'font-family': '"Courier New", Courier', - 'font-size': '24px;', + 'text-align': 'center', color: '#F0F0F0', '-webkit-user-select': 'none', 'user-select': 'none', }, - 'mjx-dialog-close:hover': { + '.mjx-dialog-button:hover': { color: 'white !important', border: '2px solid #CCC !important', }, - 'mjx-dialog-close-x': { + '.mjx-dialog-button > mjx-dialog-icon': { display: 'block', 'background-color': '#AAA', border: '1.5px solid', @@ -271,10 +296,47 @@ export abstract class DraggableDialog { 'line-height': 0, padding: '8px 0 6px', }, - 'mjx-dialog-close-x:hover': { + '.mjs-dialog-button > mjx-dialog-icon:hover': { 'background-color': '#CCC !important', }, + // + // The close button + // + 'mjx-dialog-close': { + right: '6px', + 'font-size': '24px;', + }, + + // + // The help button + // + 'mjx-dialog-help': { + left: '6px', + 'font-size': '16px;', + width: '17px', + }, + '.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 // @@ -344,6 +406,29 @@ export abstract class DraggableDialog { }, }; + 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, to change the dialog size, hold the + alt or option 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. Hold the Win or Command key and + press any of the arrow keys to move the dialog box in the given direction. + Holding a shift key as well will make the larger changes + in the size or position.

+ +

Use Tab to move among the text and 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.

+ `; + /** * @param {DialogArgs} args The data describing the dialog */ @@ -363,7 +448,10 @@ export abstract class DraggableDialog { 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.closeKey.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"]') @@ -413,15 +501,16 @@ export abstract class DraggableDialog { extraNodes.unshift(stylesheet); } // - // Create the doalog HTML tree + // 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': 'mjx-dialog-label' }, [ + adaptor.node('mjx-dialog', { 'aria-labeledby': label }, [ adaptor.node('mjx-title', {}, [ - adaptor.node('h1', { id: 'mjx-dialog-label', tabIndex: 0 }), + adaptor.node('h1', { id: label, tabIndex: 0 }), ]), adaptor.node('div', { 'data-drag': 'none', tabIndex: 0 }), ]), @@ -459,16 +548,32 @@ export abstract class DraggableDialog { '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-close-x', { 'aria-hidden': true }, [ + adaptor.node('mjx-dialog-icon', { 'aria-hidden': true }, [ adaptor.text('\u00d7'), ]), ] @@ -530,6 +635,10 @@ export abstract class DraggableDialog { * od the dialog. */ protected actions: ActionMap = { + // + // Mouse actions + // + down: { move: (d) => { d.dialog.classList.add('mjx-moving'); @@ -573,6 +682,38 @@ export abstract class DraggableDialog { 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], + }, }; /** @@ -582,7 +723,9 @@ export abstract class DraggableDialog { * @param {MouseEvent} event The event causing the action */ protected dragAction(type: string, event: MouseEvent = null) { - this.stop(event); + if (event) { + this.stop(event); + } // // Get the move/resize data for the action // @@ -598,7 +741,7 @@ export abstract class DraggableDialog { if (dw) { const W = this.w + dw; if (W >= this.minW) { - this.x = event.x; + this.x = event?.x; this.w = W; this.dialog.style.maxWidth = this.dialog.style.width = W + 'px'; } else { @@ -611,7 +754,7 @@ export abstract class DraggableDialog { if (dh) { const H = this.h + dh; if (H >= this.minH + this.title.offsetHeight) { - this.y = event.y; + this.y = event?.y; this.h = H; this.dialog.style.maxHeight = this.dialog.style.height = H + 'px'; } else { @@ -623,11 +766,11 @@ export abstract class DraggableDialog { // if (dx || dy) { if (dx) { - this.x = event.x; + this.x = event?.x; this.tx += dx || 0; } if (dy) { - this.y = event.y; + this.y = event?.y; this.ty += dy || 0; } this.dialog.style.transform = `translate(${this.tx}px, ${this.ty}px)`; @@ -706,25 +849,63 @@ export abstract class DraggableDialog { * @param {KeyboardEvent} event The key event to handle */ protected KeyDown(event: KeyboardEvent) { - if (event.code === 'Escape') { - this.closeDialog(event); - return; + const CLASS = (this.constructor as typeof DraggableDialog); + const action = CLASS.keyActions.get(event.key); + if (action) { + action(this, event); } - if (event.key === 'a' && (event.ctrlKey || event.metaKey)) { + } + + /** + * 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); - return; } } /** - * Handle the enter or space key on the close icon + * 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.dragAction(event.shiftKey ? 'bigsize' : 'keysize'); + this.stop(event); + } else if (event.metaKey) { + this.dragAction(event.shiftKey ? 'bigmove' : 'keymove'); + this.stop(event); + } + this.action = ''; + } + + /** + * Handle the enter or space key on a button icon * - * @param {KeyboardEvent} event The event to check + * @param {(event: KeyboardEvent) => void} action The action to take on enter or space + * @param {KeyboardEvent} event The event to check */ - protected closeKey(event: KeyboardEvent) { + protected actionKey(action: (event: KeyboardEvent) => void, event: KeyboardEvent) { if (event.code === 'Enter' || event.code === 'Space') { - this.closeDialog(event); + action(event); } } @@ -760,8 +941,7 @@ export abstract class DraggableDialog { // this.x = event.x; this.y = event.y; - this.w = this.dialog.clientWidth - 8; // adjust for the 4px padding on all sides - this.h = this.dialog.clientHeight - 8; + this.getWH(); this.dragging = true; // // Add the mousemove and mouseup handlers @@ -772,6 +952,14 @@ export abstract class DraggableDialog { } } + /** + * 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 */ @@ -806,6 +994,28 @@ export abstract class DraggableDialog { 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. * From d3ef2f0dc65e14d746d1a240fc35084d8d7573d2 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 25 Sep 2025 16:11:47 -0400 Subject: [PATCH 04/17] Updates for prettier --- ts/ui/dialog/DraggableDialog.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index 320679f6b..547424b19 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -448,10 +448,16 @@ export class DraggableDialog { 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))); + 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))); + help.addEventListener( + 'keydown', + this.actionKey.bind(this, this.helpDialog.bind(this, adaptor)) + ); this.noDrag = Array.from( this.dialog.querySelectorAll('[data-drag="none"]') @@ -849,7 +855,7 @@ export class DraggableDialog { * @param {KeyboardEvent} event The key event to handle */ protected KeyDown(event: KeyboardEvent) { - const CLASS = (this.constructor as typeof DraggableDialog); + const CLASS = this.constructor as typeof DraggableDialog; const action = CLASS.keyActions.get(event.key); if (action) { action(this, event); @@ -903,7 +909,10 @@ export class DraggableDialog { * @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) { + protected actionKey( + action: (event: KeyboardEvent) => void, + event: KeyboardEvent + ) { if (event.code === 'Enter' || event.code === 'Space') { action(event); } @@ -1009,8 +1018,8 @@ export class DraggableDialog { styles: { '.mjx-dialog-help': { 'max-width': 'calc(min(50em, 80%))', - } - } + }, + }, }); help.attach(); this.stop(event); @@ -1026,9 +1035,9 @@ export class DraggableDialog { 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); + const { x, y } = event; + const { left, right, top, bottom } = this.dialog.getBoundingClientRect(); + return x >= left && x <= right && y >= top && y <= bottom; } /** From bc11aba9dc45e52534b4833df300c4722df2fdfe Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 29 Aug 2025 11:36:00 -0400 Subject: [PATCH 05/17] Add option to disable explorer help --- ts/a11y/explorer.ts | 1 + ts/a11y/explorer/KeyExplorer.ts | 17 +++++++++++++---- ts/ui/menu/MenuHandler.ts | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 34af8d182..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] diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 4aa0e2652..5fc4fa436 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -619,8 +619,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(); } @@ -1072,7 +1077,7 @@ 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; @@ -1548,7 +1553,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 @@ -1584,7 +1591,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/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), From 18c9ec4debf039a5e4b8b3e974dd5c16b4a1bc84 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 17 Oct 2025 09:01:34 -0400 Subject: [PATCH 06/17] Uppdate for prettier --- ts/a11y/explorer/KeyExplorer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 5fc4fa436..c57426460 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -1077,7 +1077,10 @@ export class SpeechExplorer if (describe) { let description = this.description === this.none ? '' : ', ' + this.description; - if (this.document.options.a11y.help && this.document.options.enableExplorerHelp) { + if ( + this.document.options.a11y.help && + this.document.options.enableExplorerHelp + ) { description += ', press h for help'; } speech += description; From 4cf3a3cf3bd228c5dbd7e8793a3fc9e3ea4f765e Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Mon, 20 Oct 2025 08:43:54 -0400 Subject: [PATCH 07/17] Adjust dialog icons for better cross-platform support. Fix dialog height issue in Safari. Fix wrapping of help dialog when About MathJax dialog is open. --- ts/ui/dialog/DraggableDialog.ts | 8 ++++---- ts/ui/menu/Menu.ts | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index 547424b19..a7aa784c7 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -231,7 +231,6 @@ export class DraggableDialog { all: 'initial', cursor: 'inherit', width: '100%', - height: '100%', display: 'flex', 'flex-direction': 'column', 'flex-grow': 1, @@ -274,6 +273,7 @@ export class DraggableDialog { position: 'absolute', top: '6px', height: '17px', + width: '17px', cursor: 'default', display: 'block', border: '2px solid #AAA', @@ -305,7 +305,7 @@ export class DraggableDialog { // 'mjx-dialog-close': { right: '6px', - 'font-size': '24px;', + 'font-size': '20px;', }, // @@ -313,8 +313,8 @@ export class DraggableDialog { // 'mjx-dialog-help': { left: '6px', - 'font-size': '16px;', - width: '17px', + 'font-size': '14px;', + 'font-weight': 'bold', }, '.mjx-dialog-help mjx-dialog-help': { display: 'none', diff --git a/ts/ui/menu/Menu.ts b/ts/ui/menu/Menu.ts index 0ede2f1eb..2f91ca01f 100644 --- a/ts/ui/menu/Menu.ts +++ b/ts/ui/menu/Menu.ts @@ -334,6 +334,9 @@ export class Menu { 'mjx-dialog > div': { 'white-space': 'nowrap', }, + 'dialog.mjx-dialog-help > mjx-dialog > div': { + 'white-space': 'normal', + }, 'mjx-v': { 'font-size': '80%', }, From 2ef10bd61f1f289ef76d95f0233cab55bceda319 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Sun, 19 Oct 2025 15:41:43 -0400 Subject: [PATCH 08/17] Add color handling for dark mode --- ts/a11y/complexity/collapse.ts | 2 +- ts/a11y/explorer.ts | 15 +++++ ts/a11y/explorer/Highlighter.ts | 41 +++--------- ts/a11y/explorer/Region.ts | 100 +++++++++++++++++++++++----- ts/output/chtml.ts | 9 +++ ts/output/chtml/Wrappers/maction.ts | 19 ++++++ ts/output/svg.ts | 8 +++ ts/output/svg/Wrappers/maction.ts | 18 +++++ ts/ui/dialog/DraggableDialog.ts | 47 ++++++++++++- ts/ui/menu/Menu.ts | 20 ++++-- ts/util/StyleJson.ts | 24 +++---- 11 files changed, 234 insertions(+), 69 deletions(-) diff --git a/ts/a11y/complexity/collapse.ts b/ts/a11y/complexity/collapse.ts index f20db9065..24ce13109 100644 --- a/ts/a11y/complexity/collapse.ts +++ b/ts/a11y/complexity/collapse.ts @@ -599,7 +599,7 @@ export class Collapse { ), }, [ - factory.create('mtext', { mathcolor: 'blue', ...variant }, [ + factory.create('mtext', variant, [ (factory.create('text') as TextNode).setText(marker), ]), ] diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 8df549bc6..575533aed 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -418,6 +418,21 @@ export function ExplorerMathDocumentMixin< display: 'inline-flex', 'align-items': 'center', }, + '@media (prefers-color-scheme: dark) /* explorer */': { + 'mjx-help > svg': { + stroke: '#E0E0E0', + }, + 'mjx-help > svg > circle': { + fill: '#404040', + }, + 'mjx-help > svg > circle:nth-child(2)': { + fill: 'rgba(132, 132, 255, .3)', + }, + 'mjx-help:hover > svg > circle:nth-child(2)': { + stroke: '#AAAAAA', + fill: '#404040', + }, + } }; /** diff --git a/ts/a11y/explorer/Highlighter.ts b/ts/a11y/explorer/Highlighter.ts index 7f520914c..27d5c864f 100644 --- a/ts/a11y/explorer/Highlighter.ts +++ b/ts/a11y/explorer/Highlighter.ts @@ -21,26 +21,9 @@ interface NamedColor { color: string; alpha?: number; + type?: string; } -interface ChannelColor { - red: number; - green: number; - blue: number; - alpha?: number; -} - -const namedColors: { [key: string]: ChannelColor } = { - red: { red: 255, green: 0, blue: 0 }, - green: { red: 0, green: 255, blue: 0 }, - blue: { red: 0, green: 0, blue: 255 }, - yellow: { red: 255, green: 255, blue: 0 }, - cyan: { red: 0, green: 255, blue: 255 }, - magenta: { red: 255, green: 0, blue: 255 }, - white: { red: 255, green: 255, blue: 255 }, - black: { red: 0, green: 0, blue: 0 }, -}; - /** * Turns a named color into a channel color. * @@ -49,30 +32,22 @@ const namedColors: { [key: string]: ChannelColor } = { * @returns {string} The channel color. */ function getColorString(color: NamedColor, deflt: NamedColor): string { - const channel = namedColors[color.color] || namedColors[deflt.color]; - channel.alpha = color.alpha ?? deflt.alpha; - return rgba(channel); -} - -/** - * RGBa string version of the channel color. - * - * @param {ChannelColor} color The channel color. - * @returns {string} The color in RGBa format. - */ -function rgba(color: ChannelColor): string { - return `rgba(${color.red},${color.green},${color.blue},${color.alpha ?? 1})`; + const type = deflt.type; + const name = color.color ?? deflt.color; + const opacity = color.alpha ?? deflt.alpha; + const alpha = opacity === 1 ? 1 : `var(--mjx-${type}-alpha)`; + return `rgba(var(--mjx-${type}-${name}), ${alpha})`; } /** * The default background color if a none existing color is provided. */ -const DEFAULT_BACKGROUND: NamedColor = { color: 'blue', alpha: 1 }; +const DEFAULT_BACKGROUND: NamedColor = { color: 'blue', alpha: 1, type: 'bg' }; /** * The default color if a none existing color is provided. */ -const DEFAULT_FOREGROUND: NamedColor = { color: 'black', alpha: 1 }; +const DEFAULT_FOREGROUND: NamedColor = { color: 'black', alpha: 1, type: 'fg' }; export interface Highlighter { /** diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index 109a42d2b..374288c89 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -73,13 +73,6 @@ export abstract class AbstractRegion implements Region { */ protected static className: string; - /** - * True if the style has already been added to the document. - * - * @type {boolean} - */ - protected static styleAdded: boolean = false; - /** * The CSS style that needs to be added for this type of region. * @@ -117,20 +110,24 @@ export abstract class AbstractRegion implements Region { this.AddStyles(); } + /** + * @returns {string} The stylesheet ID + */ + public static get sheetId(): string { + return 'MJX-' + this.name + '-styles'; + } + /** * @override */ public AddStyles() { - if (this.CLASS.styleAdded) { + const id = this.CLASS.sheetId; + if (!this.CLASS.style || this.document.adaptor.head().querySelector('#' + id)) { return; } - // TODO: should that be added to document.documentStyleSheet()? - const node = this.document.adaptor.node('style'); + const node = this.document.adaptor.node('style', {id}); node.innerHTML = this.CLASS.style.cssText; - this.document.adaptor - .head(this.document.adaptor.document) - .appendChild(node); - this.CLASS.styleAdded = true; + this.document.adaptor.head().appendChild(node); } /** @@ -177,7 +174,7 @@ export abstract class AbstractRegion implements Region { */ public Hide() { if (!this.div) return; - this.div.parentNode.removeChild(this.div); + this.div.remove(); this.div = null; this.inner = null; } @@ -335,6 +332,13 @@ export class ToolTip extends StringRegion { 'border-radius': 'inherit', padding: '0 2px', }, + '@media (prefers-color-scheme: dark)': { + ['.' + ToolTip.className]: { + 'background-color': '#222025', + 'box-shadow': '0px 5px 20px #000', + border: '1px solid #7C7C7C', + }, + }, }); } @@ -348,6 +352,43 @@ export class LiveRegion extends StringRegion { * @override */ protected static style: StyleJsonSheet = new StyleJsonSheet({ + ':root': { + '--mjx-fg-red': '255, 0, 0', + '--mjx-fg-green': '0, 255, 0', + '--mjx-fg-blue': '0, 0, 255', + '--mjx-fg-yellow': '255, 255, 0', + '--mjx-fg-cyan': '0, 255, 255', + '--mjx-fg-magenta': '255, 0, 255', + '--mjx-fg-white': '255, 255, 255', + '--mjx-fg-black': '0, 0, 0', + '--mjx-bg-red': '255, 0, 0', + '--mjx-bg-green': '0, 255, 0', + '--mjx-bg-blue': '0, 0, 255', + '--mjx-bg-yellow': '255, 255, 0', + '--mjx-bg-cyan': '0, 255, 255', + '--mjx-bg-magenta': '255, 0, 255', + '--mjx-bg-white': '255, 255, 255', + '--mjx-bg-black': '0, 0, 0', + '--mjx-live-bg-color': 'white', + '--mjx-live-shadow-color': '#888', + '--mjx-live-border-color': '#CCCCCC', + '--mjx-bg-alpha': 0.2, + '--mjx-fg-alpha': 1, + }, + '@media (prefers-color-scheme: dark)': { + ':root': { + '--mjx-bg-blue': '132, 132, 255', + '--mjx-bg-white': '0, 0, 0', + '--mjx-bg-black': '255, 255, 255', + '--mjx-fg-white': '0, 0, 0', + '--mjx-fg-black': '255, 255, 255', + '--mjx-live-bg-color': '#222025', + '--mjx-live-shadow-color': 'black', + '--mjx-live-border-color': '#7C7C7C', + '--mjx-bg-alpha': 0.3, + '--mjx-fg-alpha': 1, + }, + }, ['.' + LiveRegion.className]: { position: 'absolute', top: 0, @@ -360,20 +401,36 @@ export class LiveRegion extends StringRegion { left: 0, right: 0, margin: '0 auto', - 'background-color': 'white', - 'box-shadow': '0px 5px 20px #888', - border: '2px solid #CCCCCC', + 'background-color': 'var(--mjx-live-bg-color)', + 'box-shadow': '0px 5px 20px var(--mjx-live-shadow-color)', + border: '2px solid var(--mjx-live-border-color)', }, ['.' + LiveRegion.className + '_Show']: { display: 'block', }, }); + + /** + * @param {string} type The type of alpha to set (fg or bg) + * @param {number} alpha The alpha value to use for the background + * @param {Document} document The document whose CSS styles are to be adjusted + */ + public static setAlpha(type: string, alpha: number, document: Document) { + const style = document.head.querySelector('#' + this.sheetId) as HTMLStyleElement; + if (style) { + const name = `--mjx-${type}-alpha`; + (style.sheet.cssRules[0] as any).style.setProperty(name, alpha); + (style.sheet.cssRules[1] as any).cssRules[0].style.setProperty(name, alpha ** 0.7071); + } + } } /** * Region class that enables auto voicing of content via SSML markup. */ export class SpeechRegion extends LiveRegion { + protected static style: StyleJsonSheet = null; + /** * Flag to activate auto voicing. */ @@ -583,6 +640,13 @@ export class HoverRegion extends AbstractRegion { ['.' + HoverRegion.className + ' > div']: { overflow: 'hidden', }, + '@media (prefers-color-scheme: dark)': { + ['.' + HoverRegion.className]: { + 'background-color': '#222025', + 'box-shadow': '0px 5px 20px #000', + border: '1px solid #7C7C7C', + }, + }, }); /** diff --git a/ts/output/chtml.ts b/ts/output/chtml.ts index 68bf483c0..5c33718fa 100644 --- a/ts/output/chtml.ts +++ b/ts/output/chtml.ts @@ -151,6 +151,15 @@ export class CHTML extends CommonOutputJax< 'mjx-container [inline-breaks]': { display: 'inline' }, + 'mjx-container .mjx-selected': { + outline: '2px solid black', + }, + '@media (prefers-color-scheme: dark)': { + 'mjx-container .mjx-selected': { + outline: '2px solid #C8C8C8', + }, + }, + // // These don't have Wrapper subclasses, so add their styles here // diff --git a/ts/output/chtml/Wrappers/maction.ts b/ts/output/chtml/Wrappers/maction.ts index eddafb694..9f66e54a8 100644 --- a/ts/output/chtml/Wrappers/maction.ts +++ b/ts/output/chtml/Wrappers/maction.ts @@ -188,6 +188,25 @@ export const ChtmlMaction = (function (): ChtmlMactionClass { 'background-color': '#F8F8F8', color: 'black', }, + 'mjx-maction[data-collapsible][toggle="1"]': { + color: '#55F', + }, + + '@media (prefers-color-scheme: dark) /* chtml maction */': { + 'mjx-tool > mjx-tip': { + border: '1px solid #888', + 'background-color': '#303030', + color: '#E0E0E0', + 'box-shadow': '2px 2px 5px #000', + }, + 'mjx-status': { + 'background-color': '#303030', + color: '#E0E0E0', + }, + 'mjx-maction[data-collapsible][toggle="1"]': { + color: '#88F', + }, + }, }; /** diff --git a/ts/output/svg.ts b/ts/output/svg.ts index 38844e48d..179043986 100644 --- a/ts/output/svg.ts +++ b/ts/output/svg.ts @@ -114,6 +114,14 @@ export class SVG extends CommonOutputJax< stroke: 'black', 'stroke-width': '80px', }, + '@media (prefers-color-scheme: dark)': { + [[ + 'rect[data-sre-highlighter-added]:has(+ .mjx-selected)', + 'rect[data-sre-highlighter-bbox].mjx-selected', + ].join(', ')]: { + stroke: '#C8C8C8', + }, + }, }; /** diff --git a/ts/output/svg/Wrappers/maction.ts b/ts/output/svg/Wrappers/maction.ts index 0a551cce2..298c40f11 100644 --- a/ts/output/svg/Wrappers/maction.ts +++ b/ts/output/svg/Wrappers/maction.ts @@ -187,6 +187,24 @@ export const SvgMaction = (function (): SvgMactionClass { 'background-color': '#F8F8F8', color: 'black', }, + 'g[data-mml-node="maction"][data-collapsible][data-toggle="1"]': { + fill: '#55F', + }, + + '@media (prefers-color-scheme: dark) /* svg maction */': { + 'mjx-tool > mjx-tip': { + 'background-color': '#303030', + color: '#E0E0E0', + 'box-shadow': '2px 2px 5px #000', + }, + 'mjx-status': { + 'background-color': '#303030', + color: '#E0E0E0', + }, + 'g[data-mml-node="maction"][data-collapsible][data-toggle="1"]': { + fill: '#88F', + }, + }, }; /** diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index a7aa784c7..5c8a3cbd7 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -173,6 +173,7 @@ export class DraggableDialog { * The default styles for all dialogs */ public static styles: StyleJson = { + // // For when dialog element is not available // @@ -296,7 +297,7 @@ export class DraggableDialog { 'line-height': 0, padding: '8px 0 6px', }, - '.mjs-dialog-button > mjx-dialog-icon:hover': { + '.mjx-dialog-button > mjx-dialog-icon:hover': { 'background-color': '#CCC !important', }, @@ -404,6 +405,50 @@ export class DraggableDialog { bottom: '-3px', cursor: 'nwse-resize', }, + + '@media (prefers-color-scheme: dark)': { + '.mjx-dialog': { + 'background-color': '#303030', + 'box-shadow': '0px 10px 20px #000', + border: '3px outset #7C7C7C', + }, + 'mjx-dialog': { + color: '#E0E0E0', + }, + 'mjx-dialog > div': { + border: '2px inset #7C7C7C', + 'background-color': '#222025', + }, + 'a[href]': { + color: '#86A7F5', + }, + 'a[href]:visited': { + color: '#DD98E2', + }, + 'mjx-dialog kbd': { + color: '#F8F8F8', + 'background-color': '#545454', + border: 'solid 1.5px #7A7C7E', + 'border-bottom-color': '#707070', + 'box-shadow': 'inset -.5px -1px 0 #818589', + }, + '.mjx-dialog-button': { + border: '2px solid #686868', + color: '#A4A4A4', + }, + '.mjx-dialog-button:hover': { + color: '#CBCBCB !important', + border: '2px solid #888888 !important', + }, + '.mjx-dialog-button > mjx-dialog-icon': { + 'background-color': '#646464', + }, + '.mjx-dialog-button > mjx-dialog-icon:hover': { + 'background-color': '#888888 !important', + }, + }, + + }; protected static helpMessage: string = ` diff --git a/ts/ui/menu/Menu.ts b/ts/ui/menu/Menu.ts index 2f91ca01f..68eb5c8f2 100644 --- a/ts/ui/menu/Menu.ts +++ b/ts/ui/menu/Menu.ts @@ -621,9 +621,9 @@ export class Menu { ), this.a11yVar('highlight', (value) => this.setHighlight(value)), this.a11yVar('backgroundColor'), - this.a11yVar('backgroundOpacity'), + this.a11yVar('backgroundOpacity', (value) => this.setAlpha('bg', value)), this.a11yVar('foregroundColor'), - this.a11yVar('foregroundOpacity'), + this.a11yVar('foregroundOpacity', (value) => this.setAlpha('fg', value)), this.a11yVar('subtitles'), this.a11yVar('viewBraille'), this.a11yVar('voicing'), @@ -922,8 +922,7 @@ export class Menu { AnnotationMenu.copyAnnotations(cache), '', ]); - CssStyles.addInfoStyles(this.document.document as any); - CssStyles.addMenuStyles(this.document.document as any); + CssStyles.addMenuStyles(this.document.document as Document); } /** @@ -1075,6 +1074,8 @@ export class Menu { if (renderer !== this.defaultSettings.renderer) { this.document.whenReady(() => this.setRenderer(renderer, false)); } + this.setAlpha('fg', this.settings.foregroundOpacity ?? '100'); + this.setAlpha('bg', this.settings.backgroundOpacity ?? '20'); }); } @@ -1327,6 +1328,17 @@ export class Menu { } } + /** + * @param {string} type The type of alpha to set (fg or bg) + * @param {string} value The value to set it to + */ + protected setAlpha(type: string, value: string) { + if (MathJax._?.a11y?.explorer) { + const alpha = parseInt(value) / 100; + MathJax._.a11y.explorer.Region.LiveRegion.setAlpha(type, alpha, this.document.document); + } + } + /** * Request the scaling value from the user and save it in the settings */ diff --git a/ts/util/StyleJson.ts b/ts/util/StyleJson.ts index a27de84fa..5755ab3eb 100644 --- a/ts/util/StyleJson.ts +++ b/ts/util/StyleJson.ts @@ -32,7 +32,7 @@ export type StyleJsonData = { * A list of selectors and their data (basically a stylesheet) */ export type StyleJson = { - [selector: string]: StyleJsonData; + [selector: string]: StyleJsonData | StyleJson; }; /******************************************************************************/ @@ -97,32 +97,32 @@ export class StyleJsonSheet { } /** + * @param {StyleJson} styles The style list to convert * @returns {string[]} An array of rule strings for the style list */ - public getStyleRules(): string[] { - const selectors = Object.keys(this.styles); + public getStyleRules(styles: StyleJson = this.styles): string[] { + const selectors = Object.keys(styles); const defs: string[] = new Array(selectors.length); let i = 0; for (const selector of selectors) { - defs[i++] = - selector + - ' {\n' + - this.getStyleDefString(this.styles[selector]) + - '\n}'; + const data = styles[selector]; + defs[i++] = `${selector} {\n${this.getStyleDefString(data)}\n}`; } return defs; } /** - * @param {StyleJsonData} styles The style data to be stringified - * @returns {string} The CSS string for the given data + * @param {StyleJsonData | StyleJson} styles The style data to be stringified + * @returns {string} The CSS string for the given data */ - public getStyleDefString(styles: StyleJsonData): string { + public getStyleDefString(styles: StyleJsonData | StyleJson): string { const properties = Object.keys(styles); const values: string[] = new Array(properties.length); let i = 0; for (const property of properties) { - values[i++] = ' ' + property + ': ' + styles[property] + ';'; + values[i++] = styles[property] instanceof Object + ? ' ' + this.getStyleRules({[property]: styles[property]} as StyleJson).join('\n ') + : ' ' + property + ': ' + styles[property] + ';'; } return values.join('\n'); } From 2df99776d5f48480f852d806c6684658df71993f Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Sun, 19 Oct 2025 16:33:33 -0400 Subject: [PATCH 09/17] Fixes for prettier --- ts/a11y/explorer.ts | 2 +- ts/a11y/explorer/Region.ts | 16 ++++++++++++---- ts/ui/dialog/DraggableDialog.ts | 3 --- ts/ui/menu/Menu.ts | 14 +++++++++++--- ts/util/StyleJson.ts | 10 +++++++--- 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 575533aed..a24aedb37 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -432,7 +432,7 @@ export function ExplorerMathDocumentMixin< stroke: '#AAAAAA', fill: '#404040', }, - } + }, }; /** diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index 374288c89..9feaabb23 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -122,10 +122,13 @@ export abstract class AbstractRegion implements Region { */ public AddStyles() { const id = this.CLASS.sheetId; - if (!this.CLASS.style || this.document.adaptor.head().querySelector('#' + id)) { + if ( + !this.CLASS.style || + this.document.adaptor.head().querySelector('#' + id) + ) { return; } - const node = this.document.adaptor.node('style', {id}); + const node = this.document.adaptor.node('style', { id }); node.innerHTML = this.CLASS.style.cssText; this.document.adaptor.head().appendChild(node); } @@ -416,11 +419,16 @@ export class LiveRegion extends StringRegion { * @param {Document} document The document whose CSS styles are to be adjusted */ public static setAlpha(type: string, alpha: number, document: Document) { - const style = document.head.querySelector('#' + this.sheetId) as HTMLStyleElement; + const style = document.head.querySelector( + '#' + this.sheetId + ) as HTMLStyleElement; if (style) { const name = `--mjx-${type}-alpha`; (style.sheet.cssRules[0] as any).style.setProperty(name, alpha); - (style.sheet.cssRules[1] as any).cssRules[0].style.setProperty(name, alpha ** 0.7071); + (style.sheet.cssRules[1] as any).cssRules[0].style.setProperty( + name, + alpha ** 0.7071 + ); } } } diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index 5c8a3cbd7..5e981a225 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -173,7 +173,6 @@ export class DraggableDialog { * The default styles for all dialogs */ public static styles: StyleJson = { - // // For when dialog element is not available // @@ -447,8 +446,6 @@ export class DraggableDialog { 'background-color': '#888888 !important', }, }, - - }; protected static helpMessage: string = ` diff --git a/ts/ui/menu/Menu.ts b/ts/ui/menu/Menu.ts index 68eb5c8f2..90abfdbdf 100644 --- a/ts/ui/menu/Menu.ts +++ b/ts/ui/menu/Menu.ts @@ -621,9 +621,13 @@ export class Menu { ), this.a11yVar('highlight', (value) => this.setHighlight(value)), this.a11yVar('backgroundColor'), - this.a11yVar('backgroundOpacity', (value) => this.setAlpha('bg', value)), + this.a11yVar('backgroundOpacity', (value) => + this.setAlpha('bg', value) + ), this.a11yVar('foregroundColor'), - this.a11yVar('foregroundOpacity', (value) => this.setAlpha('fg', value)), + this.a11yVar('foregroundOpacity', (value) => + this.setAlpha('fg', value) + ), this.a11yVar('subtitles'), this.a11yVar('viewBraille'), this.a11yVar('voicing'), @@ -1335,7 +1339,11 @@ export class Menu { protected setAlpha(type: string, value: string) { if (MathJax._?.a11y?.explorer) { const alpha = parseInt(value) / 100; - MathJax._.a11y.explorer.Region.LiveRegion.setAlpha(type, alpha, this.document.document); + MathJax._.a11y.explorer.Region.LiveRegion.setAlpha( + type, + alpha, + this.document.document + ); } } diff --git a/ts/util/StyleJson.ts b/ts/util/StyleJson.ts index 5755ab3eb..61611cd48 100644 --- a/ts/util/StyleJson.ts +++ b/ts/util/StyleJson.ts @@ -120,9 +120,13 @@ export class StyleJsonSheet { const values: string[] = new Array(properties.length); let i = 0; for (const property of properties) { - values[i++] = styles[property] instanceof Object - ? ' ' + this.getStyleRules({[property]: styles[property]} as StyleJson).join('\n ') - : ' ' + property + ': ' + styles[property] + ';'; + values[i++] = + styles[property] instanceof Object + ? ' ' + + this.getStyleRules({ + [property]: styles[property], + } as StyleJson).join('\n ') + : ' ' + property + ': ' + styles[property] + ';'; } return values.join('\n'); } From ff85e8dc61178b826a478d495c6ef80ba001e5f1 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 21 Oct 2025 08:55:52 -0400 Subject: [PATCH 10/17] Update ts/ui/dialog/DraggableDialog.ts Co-authored-by: Volker Sorge --- ts/ui/dialog/DraggableDialog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index a7aa784c7..9800adcfd 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -420,7 +420,7 @@ export class DraggableDialog { right-hand edge of the dialog, while up and down move the bottom edge of the dialog. Hold the Win or Command key and press any of the arrow keys to move the dialog box in the given direction. - Holding a shift key as well will make the larger changes + Holding a shift key as well will make larger changes in the size or position.

Use Tab to move among the text and buttons and links From 9b0d56701af4a30ab18f6519779179e6e65e2bb3 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Tue, 21 Oct 2025 08:56:29 -0400 Subject: [PATCH 11/17] Update ts/ui/dialog/DraggableDialog.ts Co-authored-by: Volker Sorge --- ts/ui/dialog/DraggableDialog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index 9800adcfd..663608093 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -919,7 +919,7 @@ export class DraggableDialog { } /** - * Select the contents of the dialog for copying + * Select the content of the dialog for copying */ protected selectAll() { const selection = document.getSelection(); From 86225a3d83b93ee06dcebb81ed51c9a46f524ce0 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Wed, 22 Oct 2025 19:27:59 -0400 Subject: [PATCH 12/17] Fix lint issues --- ts/ui/menu/AnnotationMenu.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/ui/menu/AnnotationMenu.ts b/ts/ui/menu/AnnotationMenu.ts index a1001d941..d7a85e0bd 100644 --- a/ts/ui/menu/AnnotationMenu.ts +++ b/ts/ui/menu/AnnotationMenu.ts @@ -42,7 +42,7 @@ 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. @@ -67,7 +67,7 @@ export function showAnnotations( * @returns {DynamicSubmenu} Method generating * the copy annotations submenu. */ -export function copyAnnotations(cache: [string, string][]) { +export function copyAnnotations(cache: [string, string][]): DynamicSubmenu { return (menu: MJContextMenu, sub: Submenu, callback: SubmenuCallback) => { const annotations = cache.slice(); cache.length = 0; From 436965d2cbb0b8c9ec7845239cb972dda36d27e1 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 24 Oct 2025 11:06:40 -0400 Subject: [PATCH 13/17] Add move/size toggle keys for use with plain arrows, adjust the help message, and close the dialog if the user moves away from the page in the browser history while one is open. --- ts/ui/dialog/DraggableDialog.ts | 90 ++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/ts/ui/dialog/DraggableDialog.ts b/ts/ui/dialog/DraggableDialog.ts index 663608093..6575376e9 100644 --- a/ts/ui/dialog/DraggableDialog.ts +++ b/ts/ui/dialog/DraggableDialog.ts @@ -149,6 +149,8 @@ export class DraggableDialog { 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')], @@ -414,21 +416,39 @@ export class DraggableDialog { 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, to change the dialog size, hold the - alt or option 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. Hold the Win or Command key and - press any of the arrow keys to move the dialog box in the given direction. - Holding a shift key as well will make larger changes - in the size or position.

- -

Use Tab to move among the text and buttons and links +

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.

+ 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 */ @@ -621,6 +641,10 @@ export class DraggableDialog { 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 // @@ -849,6 +873,16 @@ export class DraggableDialog { } } + /** + * 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 * @@ -883,6 +917,26 @@ export class DraggableDialog { } } + /** + * 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 * @@ -893,12 +947,12 @@ export class DraggableDialog { if (event.ctrlKey || this.dragging) return; this.action = direction; this.getWH(); - if (event.altKey) { - this.dragAction(event.shiftKey ? 'bigsize' : 'keysize'); - this.stop(event); - } else if (event.metaKey) { + 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 = ''; } @@ -992,7 +1046,7 @@ export class DraggableDialog { * * @param {Event} event The event that caused the closure */ - protected closeDialog(event: Event) { + protected closeDialog(event?: Event) { if (isDialog) { this.dialog.close(); this.dialog.remove(); @@ -1000,7 +1054,9 @@ export class DraggableDialog { this.background.remove(); } this.node?.focus(); - this.stop(event); + if (event) { + this.stop(event); + } } /** From 113588724355c1a19382e24147b51d1afc26658e Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 24 Oct 2025 11:11:47 -0400 Subject: [PATCH 14/17] Update explorer help to make it clear how to turn off the explorer (for sighted users who don't want to use it). --- ts/a11y/explorer/KeyExplorer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index c57426460..e333db800 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -214,7 +214,9 @@ export class SpeechExplorer 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.

+ 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, From b459898745b2eba7608b820dd9b5fec0ad4c0db1 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Sun, 26 Oct 2025 15:53:40 -0400 Subject: [PATCH 15/17] Update spacing for StyleJason for @media rules, and add a test for it --- testsuite/tests/util/StyleJson.test.ts | 16 +++++++++++++++ ts/util/StyleJson.ts | 28 +++++++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/testsuite/tests/util/StyleJson.test.ts b/testsuite/tests/util/StyleJson.test.ts index 84a013515..08d76dd2c 100644 --- a/testsuite/tests/util/StyleJson.test.ts +++ b/testsuite/tests/util/StyleJson.test.ts @@ -36,4 +36,20 @@ describe('StyleJsonSheet object', () => { expect(styles.cssText).toBe(''); }); + test('Compound style', () => { + expect(new StyleJsonSheet({ + '@media (prefers-color-scheme: dark)': { + 'mjx-container': { + 'color': '#E0E0E0', + }, + } + }).cssText).toBe([ + '@media (prefers-color-scheme: dark) {', + ' mjx-container {', + ' color: #E0E0E0;', + ' }', + '}' + ].join('\n')); + }); + }); diff --git a/ts/util/StyleJson.ts b/ts/util/StyleJson.ts index 61611cd48..f08263576 100644 --- a/ts/util/StyleJson.ts +++ b/ts/util/StyleJson.ts @@ -100,13 +100,17 @@ export class StyleJsonSheet { * @param {StyleJson} styles The style list to convert * @returns {string[]} An array of rule strings for the style list */ - public getStyleRules(styles: StyleJson = this.styles): string[] { + public getStyleRules( + styles: StyleJson = this.styles, + spaces: string = '' + ): string[] { const selectors = Object.keys(styles); const defs: string[] = new Array(selectors.length); let i = 0; for (const selector of selectors) { const data = styles[selector]; - defs[i++] = `${selector} {\n${this.getStyleDefString(data)}\n}`; + defs[i++] = + `${spaces}${selector} {\n${this.getStyleDefString(data, spaces)}\n${spaces}}`; } return defs; } @@ -115,19 +119,25 @@ export class StyleJsonSheet { * @param {StyleJsonData | StyleJson} styles The style data to be stringified * @returns {string} The CSS string for the given data */ - public getStyleDefString(styles: StyleJsonData | StyleJson): string { + public getStyleDefString( + styles: StyleJsonData | StyleJson, + spaces: string + ): string { const properties = Object.keys(styles); const values: string[] = new Array(properties.length); let i = 0; for (const property of properties) { values[i++] = styles[property] instanceof Object - ? ' ' + - this.getStyleRules({ - [property]: styles[property], - } as StyleJson).join('\n ') - : ' ' + property + ': ' + styles[property] + ';'; + ? spaces + + this.getStyleRules( + { + [property]: styles[property], + } as StyleJson, + spaces + ' ' + ).join('\n' + spaces) + : ' ' + spaces + property + ': ' + styles[property] + ';'; } - return values.join('\n'); + return values.join('\n' + spaces); } } From e3d534f3a5a9cd2b2f0c23fc38bca94c8e6844ae Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Sun, 26 Oct 2025 15:57:44 -0400 Subject: [PATCH 16/17] Add missing JSDoc parameters --- ts/util/StyleJson.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ts/util/StyleJson.ts b/ts/util/StyleJson.ts index f08263576..c37e9e365 100644 --- a/ts/util/StyleJson.ts +++ b/ts/util/StyleJson.ts @@ -98,7 +98,8 @@ export class StyleJsonSheet { /** * @param {StyleJson} styles The style list to convert - * @returns {string[]} An array of rule strings for the style list + * @param {string} spaces The spaces to put at the beginning of each line + * @returns {string[]} An array of rule strings for the style list */ public getStyleRules( styles: StyleJson = this.styles, @@ -117,7 +118,8 @@ export class StyleJsonSheet { /** * @param {StyleJsonData | StyleJson} styles The style data to be stringified - * @returns {string} The CSS string for the given data + * @param {string} spaces The spaces to put at the beginning of each line + * @returns {string} The CSS string for the given data */ public getStyleDefString( styles: StyleJsonData | StyleJson, From b6c54a12cc02132d7e3e9245d9f5c67591327197 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 13 Nov 2025 07:33:13 -0500 Subject: [PATCH 17/17] Update ts/a11y/explorer/Region.ts Co-authored-by: Volker Sorge --- ts/a11y/explorer/Region.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/a11y/explorer/Region.ts b/ts/a11y/explorer/Region.ts index 9feaabb23..1303fb7f2 100644 --- a/ts/a11y/explorer/Region.ts +++ b/ts/a11y/explorer/Region.ts @@ -415,7 +415,7 @@ export class LiveRegion extends StringRegion { /** * @param {string} type The type of alpha to set (fg or bg) - * @param {number} alpha The alpha value to use for the background + * @param {number} alpha The alpha value to use * @param {Document} document The document whose CSS styles are to be adjusted */ public static setAlpha(type: string, alpha: number, document: Document) {