From 964dea5a8b43965914ff8eb1ca69e3758a396da6 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Thu, 24 Jul 2025 18:41:11 -0700 Subject: [PATCH 01/15] Pull SuperscriptCommand class out of RHS --- src/commands/math/commands.ts | 112 +++++++++++++++++----------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index da56fe0fe..e95d48c74 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -639,68 +639,70 @@ class SubscriptCommand extends SupSub { } LatexCmds.subscript = LatexCmds._ = SubscriptCommand; -LatexCmds.superscript = - LatexCmds.supscript = - LatexCmds['^'] = - class SuperscriptCommand extends SupSub { - supsub = 'sup' as const; +class SuperscriptCommand extends SupSub { + supsub = 'sup' as const; - domView = new DOMView(1, (blocks) => - h('span', { class: 'mq-supsub mq-non-leaf mq-sup-only' }, [ - h.block('span', { class: 'mq-sup' }, blocks[0]) - ]) - ); + domView = new DOMView(1, (blocks) => + h('span', { class: 'mq-supsub mq-non-leaf mq-sup-only' }, [ + h.block('span', { class: 'mq-sup' }, blocks[0]) + ]) + ); - textTemplate = ['^(', ')']; - mathspeak(opts?: MathspeakOptions) { - // Simplify basic exponent speech for common whole numbers. - var child = this.upInto; - if (child !== undefined) { - // Calculate this item's inner text to determine whether to shorten the returned speech. - // Do not calculate its inner mathspeak now until we know that the speech is to be truncated. - // Since the mathspeak computation is recursive, we want to call it only once in this function to avoid performance bottlenecks. - var innerText = getCtrlSeqsFromBlock(child); - // If the superscript is a whole number, shorten the speech that is returned. - if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { - // Simple cases - if (innerText === '0') { - return 'to the 0 power'; - } else if (innerText === '2') { - return 'squared'; - } else if (innerText === '3') { - return 'cubed'; - } + textTemplate = ['^(', ')']; + mathspeak(opts?: MathspeakOptions) { + // Simplify basic exponent speech for common whole numbers. + var child = this.upInto; + if (child !== undefined) { + // Calculate this item's inner text to determine whether to shorten the returned speech. + // Do not calculate its inner mathspeak now until we know that the speech is to be truncated. + // Since the mathspeak computation is recursive, we want to call it only once in this function to avoid performance bottlenecks. + var innerText = getCtrlSeqsFromBlock(child); + // If the superscript is a whole number, shorten the speech that is returned. + if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { + // Simple cases + if (innerText === '0') { + return 'to the 0 power'; + } else if (innerText === '2') { + return 'squared'; + } else if (innerText === '3') { + return 'cubed'; + } - // More complex cases. - var suffix = ''; - // Limit suffix addition to exponents < 1000. - if (/^[+-]?\d{1,3}$/.test(innerText)) { - if (/(11|12|13|4|5|6|7|8|9|0)$/.test(innerText)) { - suffix = 'th'; - } else if (/1$/.test(innerText)) { - suffix = 'st'; - } else if (/2$/.test(innerText)) { - suffix = 'nd'; - } else if (/3$/.test(innerText)) { - suffix = 'rd'; - } - } - var innerMathspeak = - typeof child === 'object' ? child.mathspeak() : innerText; - return 'to the ' + innerMathspeak + suffix + ' power'; + // More complex cases. + var suffix = ''; + // Limit suffix addition to exponents < 1000. + if (/^[+-]?\d{1,3}$/.test(innerText)) { + if (/(11|12|13|4|5|6|7|8|9|0)$/.test(innerText)) { + suffix = 'th'; + } else if (/1$/.test(innerText)) { + suffix = 'st'; + } else if (/2$/.test(innerText)) { + suffix = 'nd'; + } else if (/3$/.test(innerText)) { + suffix = 'rd'; } } - return super.mathspeak(); + var innerMathspeak = + typeof child === 'object' ? child.mathspeak() : innerText; + return 'to the ' + innerMathspeak + suffix + ' power'; } + } + return super.mathspeak(); + } + + ariaLabel = 'superscript'; + mathspeakTemplate = ['Superscript,', ', Baseline']; + finalizeTree() { + this.upInto = this.sup = this.getEnd(R); + this.sup.downOutOf = insLeftOfMeUnlessAtEnd; + super.finalizeTree(); + } +}; + +LatexCmds.superscript = + LatexCmds.supscript = + LatexCmds['^'] = SuperscriptCommand; - ariaLabel = 'superscript'; - mathspeakTemplate = ['Superscript,', ', Baseline']; - finalizeTree() { - this.upInto = this.sup = this.getEnd(R); - this.sup.downOutOf = insLeftOfMeUnlessAtEnd; - super.finalizeTree(); - } - }; class SummationNotation extends MathCommand { constructor(ch: string, symbol: string, ariaLabel?: string) { From c35ba36b3b1e7100f1630e9fac7bfa4fe25fc38a Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Thu, 24 Jul 2025 18:48:29 -0700 Subject: [PATCH 02/15] Neutralize finalizeTree for SupSub Surprisingly, this fixes the bug (tests added which were failing before this commit). The `supsub` property may change from the `deleteOutOf` of a block, ref `cmd.supsub = oppositeSupsub`. --- src/commands/math/commands.ts | 18 +++++++----------- test/unit/typing.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index e95d48c74..4001a493b 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -434,6 +434,13 @@ class SupSub extends MathCommand { } } finalizeTree() { + if (this.supsub === 'sub') { + this.downInto = this.sub = this.getEnd(L); + this.sub.upOutOf = insLeftOfMeUnlessAtEnd; + } else if (this.supsub === 'sup') { + this.upInto = this.sup = this.getEnd(R); + this.sup.downOutOf = insLeftOfMeUnlessAtEnd; + } var endsL = this.getEnd(L); endsL.write = function (cursor: Cursor, ch: string) { if ( @@ -630,12 +637,6 @@ class SubscriptCommand extends SupSub { mathspeakTemplate = ['Subscript,', ', Baseline']; ariaLabel = 'subscript'; - - finalizeTree() { - this.downInto = this.sub = this.getEnd(L); - this.sub.upOutOf = insLeftOfMeUnlessAtEnd; - super.finalizeTree(); - } } LatexCmds.subscript = LatexCmds._ = SubscriptCommand; @@ -692,11 +693,6 @@ class SuperscriptCommand extends SupSub { ariaLabel = 'superscript'; mathspeakTemplate = ['Superscript,', ', Baseline']; - finalizeTree() { - this.upInto = this.sup = this.getEnd(R); - this.sup.downOutOf = insLeftOfMeUnlessAtEnd; - super.finalizeTree(); - } }; LatexCmds.superscript = diff --git a/test/unit/typing.test.js b/test/unit/typing.test.js index 776a91bf0..8c2986c39 100644 --- a/test/unit/typing.test.js +++ b/test/unit/typing.test.js @@ -1666,6 +1666,38 @@ suite('typing with auto-replaces', function () { }); }); + suite('SupSub switching between sup and sub', function() { + test('deleting sup from sup+sub gives sub', function () { + mq.typedText('x^2'); + mq.keystroke('Down'); + mq.typedText('_1') + assert.equal(mq.latex(), 'x_{1}^{2}'); + mq.keystroke('Up'); + mq.keystroke('Backspace'); + assert.equal(mq.latex(), 'x_{1}^{ }'); + mq.keystroke('Backspace'); + assert.equal(mq.latex(), 'x_{1}'); + mq.typedText(')') + assert.equal(mq.latex(), '\\left(x_{1}\\right)') + }); + + test('deleting sub from sup+sub gives sup', function () { + mq.typedText('x_1'); + mq.keystroke('Up'); + mq.typedText('^2') + assert.equal(mq.latex(), 'x_{1}^{2}'); + mq.keystroke('Down'); + mq.keystroke('Backspace'); + assert.equal(mq.latex(), 'x_{ }^{2}'); + mq.keystroke('Backspace'); + assert.equal(mq.latex(), 'x^{2}'); + mq.keystroke('End'); + mq.typedText(')') + assert.equal(mq.latex(), '\\left(x^{2}\\right)') + }); + }); + + suite('SupSub behavior options', function () { test('superscript', function () { assert.equal(mq.typedText('x^2n+y').latex(), 'x^{2n+y}'); From 6e491c1d52bdfcc8ac5e03328302462258cffe89 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:02:38 -0700 Subject: [PATCH 03/15] Remove textTemplate from SupSub It already overrides the text() method --- src/commands/math/commands.ts | 3 --- test/unit/publicapi.test.js | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 4001a493b..e6155feb2 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -632,8 +632,6 @@ class SubscriptCommand extends SupSub { ]) ); - textTemplate = ['_']; - mathspeakTemplate = ['Subscript,', ', Baseline']; ariaLabel = 'subscript'; @@ -649,7 +647,6 @@ class SuperscriptCommand extends SupSub { ]) ); - textTemplate = ['^(', ')']; mathspeak(opts?: MathspeakOptions) { // Simplify basic exponent speech for common whole numbers. var child = this.upInto; diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js index 25591fc95..dfa49c7ad 100644 --- a/test/unit/publicapi.test.js +++ b/test/unit/publicapi.test.js @@ -215,6 +215,12 @@ suite('Public API', function () { assert.equal(mq.text(), '^( )'); mq.latex('3^{4}'); assert.equal(mq.text(), '3^4'); + mq.latex('x_2'); + assert.equal(mq.text(), 'x_2'); + mq.latex('x_2^{4}'); + assert.equal(mq.text(), 'x_2^4'); + mq.latex('x_{abc}^{def}'); + assert.equal(mq.text(), 'x_(a*b*c)^(d*e*f)'); mq.latex('3x+\\ 4'); assert.equal(mq.text(), '3*x+ 4'); mq.latex('x^2'); From 06d3adb36eec0309d7a7ae45d93900720c4288d3 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:11:56 -0700 Subject: [PATCH 04/15] Add tests of existing SupSub mathspeak --- test/unit/publicapi.test.js | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js index dfa49c7ad..eb7be6e88 100644 --- a/test/unit/publicapi.test.js +++ b/test/unit/publicapi.test.js @@ -382,6 +382,58 @@ suite('Public API', function () { mq.mathspeak(), 'StartAbsoluteValue "x" EndAbsoluteValue plus left parenthesis "y" right pipe' ); + + const wholeNumberExponentPairs = [ + ['x^0', '"x" to the 0 power'], + ['x^1', '"x" to the 1st power'], + ['x^2', '"x" squared'], + ['x^3', '"x" cubed'], + ['x^4', '"x" to the 4th power'], + ['x^5', '"x" to the 5th power'], + ['x^6', '"x" to the 6th power'], + ['x^7', '"x" to the 7th power'], + ['x^8', '"x" to the 8th power'], + ['x^9', '"x" to the 9th power'], + ['x^{10}', '"x" to the 10th power'], + ['x^{11}', '"x" to the 11th power'], + ['x^{12}', '"x" to the 12th power'], + ['x^{13}', '"x" to the 13th power'], + ['x^{14}', '"x" to the 14th power'], + ['x^{15}', '"x" to the 15th power'], + ['x^{16}', '"x" to the 16th power'], + ['x^{20}', '"x" to the 20th power'], + ['x^{21}', '"x" to the 21st power'], + ['x^{22}', '"x" to the 22nd power'], + ['x^{23}', '"x" to the 23rd power'], + ['x^{24}', '"x" to the 24th power'], + ] + + for (const [latex, mathspeak] of wholeNumberExponentPairs) { + mq.latex(latex); + assertMathSpeakEqual( + mq.mathspeak(), + mathspeak + ); + } + + mq.latex('x_1'); + assertMathSpeakEqual( + mq.mathspeak(), + '"x" Subscript 1 Baseline' + ); + + mq.latex('x_1^2'); + assertMathSpeakEqual( + mq.mathspeak(), + // INCORRECT + '"x" squared' + ); + + mq.latex('x_1^y'); + assertMathSpeakEqual( + mq.mathspeak(), + '"x" Superscript 1 Baseline "y" undefined' + ); }); }); From fad923c60012acb5013465705ee4a6f0015544f7 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:15:32 -0700 Subject: [PATCH 05/15] Factor out wholeNumberPower --- src/commands/math/commands.ts | 57 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index e6155feb2..ef4d051b1 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -657,32 +657,7 @@ class SuperscriptCommand extends SupSub { var innerText = getCtrlSeqsFromBlock(child); // If the superscript is a whole number, shorten the speech that is returned. if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { - // Simple cases - if (innerText === '0') { - return 'to the 0 power'; - } else if (innerText === '2') { - return 'squared'; - } else if (innerText === '3') { - return 'cubed'; - } - - // More complex cases. - var suffix = ''; - // Limit suffix addition to exponents < 1000. - if (/^[+-]?\d{1,3}$/.test(innerText)) { - if (/(11|12|13|4|5|6|7|8|9|0)$/.test(innerText)) { - suffix = 'th'; - } else if (/1$/.test(innerText)) { - suffix = 'st'; - } else if (/2$/.test(innerText)) { - suffix = 'nd'; - } else if (/3$/.test(innerText)) { - suffix = 'rd'; - } - } - var innerMathspeak = - typeof child === 'object' ? child.mathspeak() : innerText; - return 'to the ' + innerMathspeak + suffix + ' power'; + return wholeNumberPower(child, innerText); } } return super.mathspeak(); @@ -692,6 +667,36 @@ class SuperscriptCommand extends SupSub { mathspeakTemplate = ['Superscript,', ', Baseline']; }; +/** Assumes innerText satisfies the `intRgx` */ +function wholeNumberPower(child: MQNode, innerText: string) { + // Simple cases + if (innerText === '0') { + return 'to the 0 power'; + } else if (innerText === '2') { + return 'squared'; + } else if (innerText === '3') { + return 'cubed'; + } + + // More complex cases. + var suffix = ''; + // Limit suffix addition to exponents < 1000. + if (/^[+-]?\d{1,3}$/.test(innerText)) { + if (/(11|12|13|4|5|6|7|8|9|0)$/.test(innerText)) { + suffix = 'th'; + } else if (/1$/.test(innerText)) { + suffix = 'st'; + } else if (/2$/.test(innerText)) { + suffix = 'nd'; + } else if (/3$/.test(innerText)) { + suffix = 'rd'; + } + } + var innerMathspeak = + typeof child === 'object' ? child.mathspeak() : innerText; + return 'to the ' + innerMathspeak + suffix + ' power'; +} + LatexCmds.superscript = LatexCmds.supscript = LatexCmds['^'] = SuperscriptCommand; From 2eca59edfd88635dc782bd73435a17689f8da91f Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:20:04 -0700 Subject: [PATCH 06/15] Use subscript for superscript mathspeak --- src/commands/math/commands.ts | 13 +++++++++++-- test/unit/publicapi.test.js | 3 +-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index ef4d051b1..f73ebe86b 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -620,6 +620,8 @@ function insLeftOfMeUnlessAtEnd(this: MQNode, cursor: Cursor) { return undefined; } +const subscriptMathspeakTemplate = ['Subscript,', ', Baseline']; + class SubscriptCommand extends SupSub { supsub = 'sub' as const; @@ -632,7 +634,7 @@ class SubscriptCommand extends SupSub { ]) ); - mathspeakTemplate = ['Subscript,', ', Baseline']; + mathspeakTemplate = subscriptMathspeakTemplate; ariaLabel = 'subscript'; } @@ -657,7 +659,14 @@ class SuperscriptCommand extends SupSub { var innerText = getCtrlSeqsFromBlock(child); // If the superscript is a whole number, shorten the speech that is returned. if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { - return wholeNumberPower(child, innerText); + let prefix = ''; + if (this.sub) { + prefix = + subscriptMathspeakTemplate[0] + ' ' + + this.sub.mathspeak() + ' ' + + subscriptMathspeakTemplate[1] + ' '; + } + return prefix + wholeNumberPower(child, innerText); } } return super.mathspeak(); diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js index eb7be6e88..af2dac31a 100644 --- a/test/unit/publicapi.test.js +++ b/test/unit/publicapi.test.js @@ -425,8 +425,7 @@ suite('Public API', function () { mq.latex('x_1^2'); assertMathSpeakEqual( mq.mathspeak(), - // INCORRECT - '"x" squared' + '"x" Subscript 1 Baseline squared' ); mq.latex('x_1^y'); From 0eba584e8b2f25a6a903b278add870b8470506a7 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:20:45 -0700 Subject: [PATCH 07/15] Rename child to sup --- src/commands/math/commands.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index f73ebe86b..5a5b3638f 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -651,12 +651,11 @@ class SuperscriptCommand extends SupSub { mathspeak(opts?: MathspeakOptions) { // Simplify basic exponent speech for common whole numbers. - var child = this.upInto; - if (child !== undefined) { + if (this.sup !== undefined) { // Calculate this item's inner text to determine whether to shorten the returned speech. // Do not calculate its inner mathspeak now until we know that the speech is to be truncated. // Since the mathspeak computation is recursive, we want to call it only once in this function to avoid performance bottlenecks. - var innerText = getCtrlSeqsFromBlock(child); + var innerText = getCtrlSeqsFromBlock(this.sup); // If the superscript is a whole number, shorten the speech that is returned. if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { let prefix = ''; @@ -666,7 +665,7 @@ class SuperscriptCommand extends SupSub { this.sub.mathspeak() + ' ' + subscriptMathspeakTemplate[1] + ' '; } - return prefix + wholeNumberPower(child, innerText); + return prefix + wholeNumberPower(this.sup, innerText); } } return super.mathspeak(); @@ -677,7 +676,7 @@ class SuperscriptCommand extends SupSub { }; /** Assumes innerText satisfies the `intRgx` */ -function wholeNumberPower(child: MQNode, innerText: string) { +function wholeNumberPower(sup: MQNode, innerText: string) { // Simple cases if (innerText === '0') { return 'to the 0 power'; @@ -702,7 +701,7 @@ function wholeNumberPower(child: MQNode, innerText: string) { } } var innerMathspeak = - typeof child === 'object' ? child.mathspeak() : innerText; + typeof sup === 'object' ? sup.mathspeak() : innerText; return 'to the ' + innerMathspeak + suffix + ' power'; } From b96eec2becd80052178d3c24647c8ff425bfcef4 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:23:10 -0700 Subject: [PATCH 08/15] Neutralize mathspeak for SupSub --- src/commands/math/commands.ts | 43 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 5a5b3638f..d235d2845 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -522,6 +522,27 @@ class SupSub extends MathCommand { this.checkCursorContextClose(ctx); } + mathspeak(opts?: MathspeakOptions) { + // Simplify basic exponent speech for common whole numbers. + if (this.sup !== undefined) { + // Calculate this item's inner text to determine whether to shorten the returned speech. + // Do not calculate its inner mathspeak now until we know that the speech is to be truncated. + // Since the mathspeak computation is recursive, we want to call it only once in this function to avoid performance bottlenecks. + var innerText = getCtrlSeqsFromBlock(this.sup); + // If the superscript is a whole number, shorten the speech that is returned. + if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { + let prefix = ''; + if (this.sub) { + prefix = + subscriptMathspeakTemplate[0] + ' ' + + this.sub.mathspeak() + ' ' + + subscriptMathspeakTemplate[1] + ' '; + } + return prefix + wholeNumberPower(this.sup, innerText); + } + } + return super.mathspeak(); + } text() { function text(prefix: string, block: NodeRef | undefined) { var l = (block && block.text()) || ''; @@ -649,28 +670,6 @@ class SuperscriptCommand extends SupSub { ]) ); - mathspeak(opts?: MathspeakOptions) { - // Simplify basic exponent speech for common whole numbers. - if (this.sup !== undefined) { - // Calculate this item's inner text to determine whether to shorten the returned speech. - // Do not calculate its inner mathspeak now until we know that the speech is to be truncated. - // Since the mathspeak computation is recursive, we want to call it only once in this function to avoid performance bottlenecks. - var innerText = getCtrlSeqsFromBlock(this.sup); - // If the superscript is a whole number, shorten the speech that is returned. - if ((!opts || !opts.ignoreShorthand) && intRgx.test(innerText)) { - let prefix = ''; - if (this.sub) { - prefix = - subscriptMathspeakTemplate[0] + ' ' + - this.sub.mathspeak() + ' ' + - subscriptMathspeakTemplate[1] + ' '; - } - return prefix + wholeNumberPower(this.sup, innerText); - } - } - return super.mathspeak(); - } - ariaLabel = 'superscript'; mathspeakTemplate = ['Superscript,', ', Baseline']; }; From 0db3933c47a3f305a781d919a716d8b7abebad6f Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 13:49:11 -0700 Subject: [PATCH 09/15] Neutralize mathspeak template --- src/commands/math/commands.ts | 21 +++++++++++++++------ src/services/aria.ts | 2 ++ test/unit/publicapi.test.js | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index d235d2845..93c0e3a09 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -534,15 +534,25 @@ class SupSub extends MathCommand { let prefix = ''; if (this.sub) { prefix = - subscriptMathspeakTemplate[0] + ' ' + + subMathspeakTemplate[0] + ' ' + this.sub.mathspeak() + ' ' + - subscriptMathspeakTemplate[1] + ' '; + subMathspeakTemplate[1] + ' '; } return prefix + wholeNumberPower(this.sup, innerText); } } + this.mathspeakTemplate = this.getMathspeakTemplate(); return super.mathspeak(); } + private getMathspeakTemplate() { + if (this.sub && this.sup) { + return supSubMathspeakTemplate; + } else if (this.sup) { + return supMathspeakTemplate; + } else { + return subMathspeakTemplate + } + } text() { function text(prefix: string, block: NodeRef | undefined) { var l = (block && block.text()) || ''; @@ -641,7 +651,9 @@ function insLeftOfMeUnlessAtEnd(this: MQNode, cursor: Cursor) { return undefined; } -const subscriptMathspeakTemplate = ['Subscript,', ', Baseline']; +const subMathspeakTemplate = ['Subscript,', ', Baseline']; +const supMathspeakTemplate = ['Superscript,', ', Baseline']; +const supSubMathspeakTemplate = ['Subscript,',', Baseline Superscript,', ', Baseline']; class SubscriptCommand extends SupSub { supsub = 'sub' as const; @@ -655,8 +667,6 @@ class SubscriptCommand extends SupSub { ]) ); - mathspeakTemplate = subscriptMathspeakTemplate; - ariaLabel = 'subscript'; } LatexCmds.subscript = LatexCmds._ = SubscriptCommand; @@ -671,7 +681,6 @@ class SuperscriptCommand extends SupSub { ); ariaLabel = 'superscript'; - mathspeakTemplate = ['Superscript,', ', Baseline']; }; /** Assumes innerText satisfies the `intRgx` */ diff --git a/src/services/aria.ts b/src/services/aria.ts index 7e5df7520..77e18cd5a 100755 --- a/src/services/aria.ts +++ b/src/services/aria.ts @@ -40,6 +40,8 @@ class Aria { // Some constructs include verbal shorthand (such as simple fractions and exponents). // Since ARIA alerts relate to moving through interactive content, we don't want to use that shorthand if it exists // since doing so may be ambiguous or confusing. + // For example, `x^2` normally has mathspeak '"x" squared', but when moving the cursor before the exponent, + // it speaks 'before Superscript, 2 , Baseline' since the alternative is 'before squared'. var itemMathspeak = item.mathspeak({ ignoreShorthand: true }); if (shouldDescribe) { // used to ensure item is described when cursor reaches block boundaries diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js index af2dac31a..0dd016129 100644 --- a/test/unit/publicapi.test.js +++ b/test/unit/publicapi.test.js @@ -431,7 +431,7 @@ suite('Public API', function () { mq.latex('x_1^y'); assertMathSpeakEqual( mq.mathspeak(), - '"x" Superscript 1 Baseline "y" undefined' + '"x" Subscript 1 Baseline Superscript "y" Baseline' ); }); }); From 106e362fc9f401a4e888612d3874cbb7bb47449f Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 14:57:30 -0700 Subject: [PATCH 10/15] Neutralize ariaLabel for SupSub --- src/commands/math/commands.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 93c0e3a09..ee6c48c8d 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -353,6 +353,14 @@ class SupSub extends MathCommand { protected ends: Ends; + constructor() { + super(); + // Note the ariaLabel doesn't change if the SupSub is edited between subscript and superscript. + // That may be a bug, though I don't know where the ariaLabel is actually used; the mathspeak + // method doesn't reference it. + this.ariaLabel = this.supsub === 'sub' ? 'subscript' : 'superscript'; + } + setEnds(ends: Ends) { pray( 'SupSub ends must be MathBlocks', @@ -666,8 +674,6 @@ class SubscriptCommand extends SupSub { ]) ]) ); - - ariaLabel = 'subscript'; } LatexCmds.subscript = LatexCmds._ = SubscriptCommand; @@ -679,8 +685,6 @@ class SuperscriptCommand extends SupSub { h.block('span', { class: 'mq-sup' }, blocks[0]) ]) ); - - ariaLabel = 'superscript'; }; /** Assumes innerText satisfies the `intRgx` */ From f7a951207f08f1548b9831f2365a041754552e27 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 15:14:13 -0700 Subject: [PATCH 11/15] Neutralize domView for SupSub --- src/commands/math/commands.ts | 66 ++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index ee6c48c8d..ece17af4d 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -345,22 +345,57 @@ function getCtrlSeqsFromBlock(block: NodeRef): string { Options.prototype.charsThatBreakOutOfSupSub = ''; +/** + * A SupSub node is a superscript, subscript, or both. It is possible to edit a SupSub node + * from being a superscript to a subscript without deleting the node by adding a subscript + * then deleting the superscript. + */ class SupSub extends MathCommand { - ctrlSeq = '_{...}^{...}'; sub?: MathBlock; sup?: MathBlock; + /** + * supsub is the initial orientation of the SupSub node; it provides the location to put + * the first block seen in parsing, either in the superscript or subscript. + */ supsub: 'sup' | 'sub'; protected ends: Ends; - constructor() { - super(); + constructor(supsub: 'sup' | 'sub') { + const ctrlSeq = '_{...}^{...}'; + + let domView; + + // Note this.domView doesn't change if the SupSub is edited to something that has both + // superscript and subscript. This is correct, since domView is only used for the initial + // creation of the HTML node, not for any updates. + if (supsub === 'sub') { + domView = new DOMView(1, (blocks) => + h('span', { class: 'mq-supsub mq-non-leaf' }, [ + h.block('span', { class: 'mq-sub' }, blocks[0]), + h('span', { style: 'display:inline-block;width:0' }, [ + h.text(U_ZERO_WIDTH_SPACE) + ]) + ]) + ); + } else { + domView = new DOMView(1, (blocks) => + h('span', { class: 'mq-supsub mq-non-leaf mq-sup-only' }, [ + h.block('span', { class: 'mq-sup' }, blocks[0]) + ]) + ); + } + + super(ctrlSeq, domView); + // Note the ariaLabel doesn't change if the SupSub is edited between subscript and superscript. // That may be a bug, though I don't know where the ariaLabel is actually used; the mathspeak // method doesn't reference it. - this.ariaLabel = this.supsub === 'sub' ? 'subscript' : 'superscript'; + this.ariaLabel = supsub === 'sub' ? 'subscript' : 'superscript'; + this.supsub = supsub; } + setEnds(ends: Ends) { pray( 'SupSub ends must be MathBlocks', @@ -664,27 +699,16 @@ const supMathspeakTemplate = ['Superscript,', ', Baseline']; const supSubMathspeakTemplate = ['Subscript,',', Baseline Superscript,', ', Baseline']; class SubscriptCommand extends SupSub { - supsub = 'sub' as const; - - domView = new DOMView(1, (blocks) => - h('span', { class: 'mq-supsub mq-non-leaf' }, [ - h.block('span', { class: 'mq-sub' }, blocks[0]), - h('span', { style: 'display:inline-block;width:0' }, [ - h.text(U_ZERO_WIDTH_SPACE) - ]) - ]) - ); + constructor() { + super('sub'); + } } LatexCmds.subscript = LatexCmds._ = SubscriptCommand; class SuperscriptCommand extends SupSub { - supsub = 'sup' as const; - - domView = new DOMView(1, (blocks) => - h('span', { class: 'mq-supsub mq-non-leaf mq-sup-only' }, [ - h.block('span', { class: 'mq-sup' }, blocks[0]) - ]) - ); + constructor() { + super('sup'); + } }; /** Assumes innerText satisfies the `intRgx` */ From 4ac4ccf6f90796382bfe18123e3f286b3ced37ae Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 15:47:05 -0700 Subject: [PATCH 12/15] Remove SubscriptCommand and SuperscriptCommand classes --- src/commands/math/basicSymbols.ts | 2 +- src/commands/math/commands.ts | 17 +++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/commands/math/basicSymbols.ts b/src/commands/math/basicSymbols.ts index 53d69eaa7..eb7195761 100644 --- a/src/commands/math/basicSymbols.ts +++ b/src/commands/math/basicSymbols.ts @@ -242,7 +242,7 @@ class Digit extends DigitGroupingChar { cursorLL instanceof Variable && cursorLL.isItalic !== false)) ) { - new SubscriptCommand().createLeftOf(cursor); + new SupSub('sub').createLeftOf(cursor); super.createLeftOf(cursor); cursor.insRightOf(cursor.parent.parent); } else super.createLeftOf(cursor); diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index ece17af4d..e8a0019b6 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -698,18 +698,6 @@ const subMathspeakTemplate = ['Subscript,', ', Baseline']; const supMathspeakTemplate = ['Superscript,', ', Baseline']; const supSubMathspeakTemplate = ['Subscript,',', Baseline Superscript,', ', Baseline']; -class SubscriptCommand extends SupSub { - constructor() { - super('sub'); - } -} -LatexCmds.subscript = LatexCmds._ = SubscriptCommand; - -class SuperscriptCommand extends SupSub { - constructor() { - super('sup'); - } -}; /** Assumes innerText satisfies the `intRgx` */ function wholeNumberPower(sup: MQNode, innerText: string) { @@ -741,10 +729,11 @@ function wholeNumberPower(sup: MQNode, innerText: string) { return 'to the ' + innerMathspeak + suffix + ' power'; } +LatexCmds.subscript = LatexCmds._ = () => new SupSub('sub'); + LatexCmds.superscript = LatexCmds.supscript = - LatexCmds['^'] = SuperscriptCommand; - + LatexCmds['^'] = () => new SupSub('sup'); class SummationNotation extends MathCommand { constructor(ch: string, symbol: string, ariaLabel?: string) { From ea2f147032c137e81b8e433485b6208f47865587 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 15:55:33 -0700 Subject: [PATCH 13/15] Remove IIFE in addBlock --- src/commands/math/commands.ts | 77 ++++++++++++++++------------------- 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index e8a0019b6..66b896b81 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -636,48 +636,41 @@ class SupSub extends MathCommand { ); } - // like 'sub sup'.split(' ').forEach(function(supsub) { ... }); - for (var i = 0; i < 2; i += 1) - (function ( - cmd: SupSub, - supsub: 'sup' | 'sub', - oppositeSupsub: 'sup' | 'sub', - updown: 'up' | 'down' - ) { - const cmdSubSub = cmd[supsub]!; - cmdSubSub.deleteOutOf = function (dir: Direction, cursor: Cursor) { - cursor.insDirOf(this[dir] ? (-dir as Direction) : dir, this.parent); - if (!this.isEmpty()) { - var end = this.getEnd(dir); - this.children() - .disown() - .withDirAdopt( - dir, - cursor.parent, - cursor[dir], - cursor[-dir as Direction] - ) - .domFrag() - .insDirOf(-dir as Direction, cursor.domFrag()); - cursor[-dir as Direction] = end; - } - cmd.supsub = oppositeSupsub; - delete cmd[supsub]; - delete cmd[`${updown}Into`]; - const cmdOppositeSupsub = cmd[oppositeSupsub]!; - cmdOppositeSupsub[`${updown}OutOf`] = insLeftOfMeUnlessAtEnd; - delete (cmdOppositeSupsub as any).deleteOutOf; // TODO - refactor so this method can be optional - if (supsub === 'sub') { - cmd.domFrag().addClass('mq-sup-only').children().last().remove(); - } - this.remove(); - }; - })( - this, - 'sub sup'.split(' ')[i] as 'sup' | 'sup', - 'sup sub'.split(' ')[i] as 'sup' | 'sup', - 'down up'.split(' ')[i] as 'up' | 'down' - ); + for (let i = 0; i < 2; i += 1) { + const cmd: SupSub = this; + const supsub = (['sub', 'sup'] as const)[i]; + const oppositeSupsub = (['sup', 'sub'] as const)[i]; + const updown = (['down', 'up'] as const)[i]; + const cmdSubSub = cmd[supsub]!; + + cmdSubSub.deleteOutOf = function (dir: Direction, cursor: Cursor) { + cursor.insDirOf(this[dir] ? (-dir as Direction) : dir, this.parent); + if (!this.isEmpty()) { + const end = this.getEnd(dir); + this.children() + .disown() + .withDirAdopt( + dir, + cursor.parent, + cursor[dir], + cursor[-dir as Direction] + ) + .domFrag() + .insDirOf(-dir as Direction, cursor.domFrag()); + cursor[-dir as Direction] = end; + } + cmd.supsub = oppositeSupsub; + delete cmd[supsub]; + delete cmd[`${updown}Into`]; + const cmdOppositeSupsub = cmd[oppositeSupsub]!; + cmdOppositeSupsub[`${updown}OutOf`] = insLeftOfMeUnlessAtEnd; + delete (cmdOppositeSupsub as any).deleteOutOf; // TODO - refactor so this method can be optional + if (supsub === 'sub') { + cmd.domFrag().addClass('mq-sup-only').children().last().remove(); + } + this.remove(); + }; + } } } From 20ed51b5fc380b45b839c4289efa7496a5f5b491 Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 17:46:38 -0700 Subject: [PATCH 14/15] Add SupSub comments --- src/commands/math/commands.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 66b896b81..5a25343eb 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -354,8 +354,17 @@ class SupSub extends MathCommand { sub?: MathBlock; sup?: MathBlock; /** - * supsub is the initial orientation of the SupSub node; it provides the location to put - * the first block seen in parsing, either in the superscript or subscript. + * `supsub` is the current or planned shape of the SupSub node. + * + * It is set before intializing to know where to put the first block seen in parsing, + * either in the superscript or subscript. This is necessary e.g. because the SupSub + * in both `x_2` and `x^2` have a single MathBlock child, but the child goes to the + * subscript in one and the exponent in the other. + * + * After initialization, either the `sub` or `sup` properties of the `SubSub` are set + * at all times. If only one is set, the `supsub` property says which one is set. + * If both are set, the `supsub` property could be either 'sup' or 'sub' (it happens + * to be whichever state the SupSub was in before the second child block was added). */ supsub: 'sup' | 'sub'; @@ -605,6 +614,10 @@ class SupSub extends MathCommand { } return text('_', this.sub) + text('^', this.sup); } + // This function is called, for example, when parsing `x_1^2`. + // In that case, first a `SupSub("sup")` is created (i.e. a superscript) representing `x^2`, + // (with the superscript `2` being added in `finalizeTree`), then the subscript `1` is added + // with `addBlock`. addBlock(block: MathBlock) { if (this.supsub === 'sub') { this.sup = this.upInto = (this.sub as MQNode).upOutOf = block; From f67efbc5950fbe00cd2198d3f310ec381850c15f Mon Sep 17 00:00:00 2001 From: Jared Hughes Date: Fri, 25 Jul 2025 18:16:27 -0700 Subject: [PATCH 15/15] Prettier fix --- src/commands/math/commands.ts | 25 +++++++++++++++---------- test/unit/publicapi.test.js | 19 +++++-------------- test/unit/typing.test.js | 15 +++++++-------- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 5a25343eb..3be4766b6 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -404,7 +404,6 @@ class SupSub extends MathCommand { this.supsub = supsub; } - setEnds(ends: Ends) { pray( 'SupSub ends must be MathBlocks', @@ -586,9 +585,12 @@ class SupSub extends MathCommand { let prefix = ''; if (this.sub) { prefix = - subMathspeakTemplate[0] + ' ' + - this.sub.mathspeak() + ' ' + - subMathspeakTemplate[1] + ' '; + subMathspeakTemplate[0] + + ' ' + + this.sub.mathspeak() + + ' ' + + subMathspeakTemplate[1] + + ' '; } return prefix + wholeNumberPower(this.sup, innerText); } @@ -602,7 +604,7 @@ class SupSub extends MathCommand { } else if (this.sup) { return supMathspeakTemplate; } else { - return subMathspeakTemplate + return subMathspeakTemplate; } } text() { @@ -702,8 +704,11 @@ function insLeftOfMeUnlessAtEnd(this: MQNode, cursor: Cursor) { const subMathspeakTemplate = ['Subscript,', ', Baseline']; const supMathspeakTemplate = ['Superscript,', ', Baseline']; -const supSubMathspeakTemplate = ['Subscript,',', Baseline Superscript,', ', Baseline']; - +const supSubMathspeakTemplate = [ + 'Subscript,', + ', Baseline Superscript,', + ', Baseline' +]; /** Assumes innerText satisfies the `intRgx` */ function wholeNumberPower(sup: MQNode, innerText: string) { @@ -730,8 +735,7 @@ function wholeNumberPower(sup: MQNode, innerText: string) { suffix = 'rd'; } } - var innerMathspeak = - typeof sup === 'object' ? sup.mathspeak() : innerText; + var innerMathspeak = typeof sup === 'object' ? sup.mathspeak() : innerText; return 'to the ' + innerMathspeak + suffix + ' power'; } @@ -739,7 +743,8 @@ LatexCmds.subscript = LatexCmds._ = () => new SupSub('sub'); LatexCmds.superscript = LatexCmds.supscript = - LatexCmds['^'] = () => new SupSub('sup'); + LatexCmds['^'] = + () => new SupSub('sup'); class SummationNotation extends MathCommand { constructor(ch: string, symbol: string, ariaLabel?: string) { diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js index 0dd016129..be1626d9a 100644 --- a/test/unit/publicapi.test.js +++ b/test/unit/publicapi.test.js @@ -405,28 +405,19 @@ suite('Public API', function () { ['x^{21}', '"x" to the 21st power'], ['x^{22}', '"x" to the 22nd power'], ['x^{23}', '"x" to the 23rd power'], - ['x^{24}', '"x" to the 24th power'], - ] + ['x^{24}', '"x" to the 24th power'] + ]; for (const [latex, mathspeak] of wholeNumberExponentPairs) { mq.latex(latex); - assertMathSpeakEqual( - mq.mathspeak(), - mathspeak - ); + assertMathSpeakEqual(mq.mathspeak(), mathspeak); } mq.latex('x_1'); - assertMathSpeakEqual( - mq.mathspeak(), - '"x" Subscript 1 Baseline' - ); + assertMathSpeakEqual(mq.mathspeak(), '"x" Subscript 1 Baseline'); mq.latex('x_1^2'); - assertMathSpeakEqual( - mq.mathspeak(), - '"x" Subscript 1 Baseline squared' - ); + assertMathSpeakEqual(mq.mathspeak(), '"x" Subscript 1 Baseline squared'); mq.latex('x_1^y'); assertMathSpeakEqual( diff --git a/test/unit/typing.test.js b/test/unit/typing.test.js index 8c2986c39..cc5e10d2a 100644 --- a/test/unit/typing.test.js +++ b/test/unit/typing.test.js @@ -1666,25 +1666,25 @@ suite('typing with auto-replaces', function () { }); }); - suite('SupSub switching between sup and sub', function() { + suite('SupSub switching between sup and sub', function () { test('deleting sup from sup+sub gives sub', function () { mq.typedText('x^2'); mq.keystroke('Down'); - mq.typedText('_1') + mq.typedText('_1'); assert.equal(mq.latex(), 'x_{1}^{2}'); mq.keystroke('Up'); mq.keystroke('Backspace'); assert.equal(mq.latex(), 'x_{1}^{ }'); mq.keystroke('Backspace'); assert.equal(mq.latex(), 'x_{1}'); - mq.typedText(')') - assert.equal(mq.latex(), '\\left(x_{1}\\right)') + mq.typedText(')'); + assert.equal(mq.latex(), '\\left(x_{1}\\right)'); }); test('deleting sub from sup+sub gives sup', function () { mq.typedText('x_1'); mq.keystroke('Up'); - mq.typedText('^2') + mq.typedText('^2'); assert.equal(mq.latex(), 'x_{1}^{2}'); mq.keystroke('Down'); mq.keystroke('Backspace'); @@ -1692,12 +1692,11 @@ suite('typing with auto-replaces', function () { mq.keystroke('Backspace'); assert.equal(mq.latex(), 'x^{2}'); mq.keystroke('End'); - mq.typedText(')') - assert.equal(mq.latex(), '\\left(x^{2}\\right)') + mq.typedText(')'); + assert.equal(mq.latex(), '\\left(x^{2}\\right)'); }); }); - suite('SupSub behavior options', function () { test('superscript', function () { assert.equal(mq.typedText('x^2n+y').latex(), 'x^{2n+y}');