From dcfa592dc3998d7f8dce393293077079b7f18de1 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 31 Jul 2026 20:04:50 -0500 Subject: [PATCH] feat: unparen --- .gitignore | 4 +++ src/commands/math/commands.ts | 53 +++++++++++++++++++++++++++++++++++ test/unit/typing.test.js | 39 ++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/.gitignore b/.gitignore index febbb5c96..e79d50807 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /node_modules /build + +.idea/ +.vscode/ + diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 395515cfb..05b746696 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -1325,6 +1325,8 @@ class Bracket extends DelimsNode { [L]: { ch: string; ctrlSeq: string }; [R]: { ch: string; ctrlSeq: string }; }; + // Skip mathspeak and instead emit an empty string. + skipMathspeak = false; constructor( side: BracketSide, open: string, @@ -1404,6 +1406,7 @@ class Bracket extends DelimsNode { this.checkCursorContextClose(ctx); } mathspeak(opts?: MathspeakOptions) { + if (this.skipMathspeak) return ''; var open = this.sides[L].ch, close = this.sides[R].ch; if (open === '|' && close === '|') { @@ -1454,7 +1457,57 @@ class Bracket extends DelimsNode { .removeClass('mq-ghost'); this.replaceBracket(brackFrag, this.side); } + + // Returns true iff handled the insertion. + unwrapSelectedBracket(cursor: Cursor) { + var seln = this.replacedFragment; + if (!seln) return false; + + var inner = seln.getEnd(L); + if ( // do we have a matching bracket pair? + !(inner instanceof Bracket) || + inner !== seln.getEnd(R) || + inner.sides[L].ch !== this.sides[L].ch || + inner.sides[R].ch !== this.sides[R].ch + ) { + return false; + } + + var innerBlock = inner.getEnd(L); + // Nothing to unwrap if the bracket is empty; fall back to wrapping. + if (innerBlock.isEmpty()) return false; + + var innerR = innerBlock.getEnd(R) as MQNode; + + // Move the bracket's children to where the selection was + // and drop the bracket that was there + var children = innerBlock.children(); + var brackFrag = seln.domFrag(); + children.disown().adopt(cursor.parent, cursor[L], cursor[R]); + children.domFrag().insertBefore(brackFrag); + brackFrag.remove(); + + cursor.parent.bubble(function (node) { + node.reflow(); + return undefined; + }); + // remove the selection + cursor.insRightOf(innerR); + + // Skip the announcement for the otherwise to-be created bracket and + // instead announce the unwrapped contents. + this.skipMathspeak = true; + cursor.controller.aria.queue( + children + .fold('', function (speech, child) { + return speech + ' ' + child.mathspeak(); + }) + .trim() + ); + return true; + } createLeftOf(cursor: Cursor) { + if (this.unwrapSelectedBracket(cursor)) return; var brack; if (!this.replacedFragment) { // unless wrapping seln in brackets, diff --git a/test/unit/typing.test.js b/test/unit/typing.test.js index e697ee2e9..2d11359dc 100644 --- a/test/unit/typing.test.js +++ b/test/unit/typing.test.js @@ -766,6 +766,45 @@ suite('typing with auto-replaces', function () { assertLatex('1+2+3+4'); }); + test('wrapping a bracketed selection in the same bracket unwraps it', function () { + mq.typedText('(ab)'); + assertLatex('\\left(ab\\right)'); + mq.keystroke('Shift-Home').typedText('('); + assertLatex('ab'); + // cursor should go after contents + // so typing "(" inserts a new bracket + mq.typedText('('); + assertLatex('ab\\left(\\right)'); + }); + + test('unwrapping only happens when the selection is exactly one bracket', function () { + mq.typedText('(a)(b)'); + assertLatex('\\left(a\\right)\\left(b\\right)'); + mq.keystroke('Shift-Home').typedText('('); + assertLatex('\\left(\\left(a\\right)\\left(b\\right)\\right)'); + }); + + test('unwrapping only happens when the bracket type matches', function () { + mq.typedText('(ab)'); + assertLatex('\\left(ab\\right)'); + mq.keystroke('Shift-Home').typedText('['); + assertLatex('\\left[\\left(ab\\right)\\right]'); + }); + + test('does not unwrap a mismatched bracket', function () { + mq.typedText('(ab]'); + assertLatex('\\left(ab\\right]'); + mq.keystroke('Shift-Home').typedText('('); + assertLatex('\\left(\\left(ab\\right]\\right)'); + }); + + test('wrapping an empty bracket in the same bracket wraps rather than unwraps', function () { + mq.typedText('()'); + assertLatex('\\left(\\right)'); + mq.keystroke('Shift-Home').typedText('('); + assertLatex('\\left(\\left(\\right)\\right)'); + }); + test('backspacing close-bracket of 1+(2+3] (nothing after) then typing', function () { mq.typedText('1+(2+3]'); assertLatex('1+\\left(2+3\\right]');