Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions src/editor-mathfield/mode-editor-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parseLatex } from '../core/parser';
import { fromJson } from '../core/atom';
import { Atom } from '../core/atom-class';
import { ArrayAtom } from '../atoms/array';
import { GroupAtom } from '../atoms/group';
import { LeftRightAtom } from '../atoms/leftright';

import { range } from '../editor-model/selection-utils';
Expand Down Expand Up @@ -327,6 +328,44 @@ export class MathModeEditor extends ModeEditor {

const cursor = model.at(model.position);
if (cursor) {
// If the inserted and existing scripts conflict, group the new base
// for example, `2^2` followed by `^2` becomes `{2^2}^2`
const subsup = cursor.rightSibling;
const lastAtom = newAtoms[newAtoms.length - 1];
if (
subsup?.type === 'subsup' &&
lastAtom &&
((lastAtom.superscript && subsup.superscript) ||
(lastAtom.subscript && subsup.subscript))
) {
// A standalone script needs its complete number or delimited base.
let start = newAtoms.length - (lastAtom.type === 'subsup' ? 2 : 1);
if (newAtoms[start]?.type === 'mclose') {
let depth = 0;
for (let i = start; i >= 0; i--) {
if (newAtoms[i].type === 'mclose') depth++;
else if (newAtoms[i].type === 'mopen' && --depth === 0) {
start = i;
break;
}
}
} else if (newAtoms[start]?.isDigit()) {
while (
start > 0 &&
newAtoms[start - 1].isDigit() &&
!newAtoms[start - 1].superscript &&
!newAtoms[start - 1].subscript
)
start--;
}
if (start >= 0) {
newAtoms = [
...newAtoms.slice(0, start),
new GroupAtom(newAtoms.slice(start), 'math'),
];
}
}

cursor.parent!.addChildrenAfter(newAtoms, cursor);
} else {
// cursor can be null when deferred implicit arg deletion
Expand Down Expand Up @@ -552,14 +591,14 @@ function getImplicitArgOffset(model: _Model): Offset {
let afterDelim = false;

if (atom.type === 'mclose') {
const delim = LEFT_DELIM[atom.value];
while (
!atom.isFirstSibling &&
!(atom.type === 'mopen' && atom.value === delim)
) {
// Count nesting, allowing mixed delimiters such as interval endpoints.
let depth = 1;
while (!atom.isFirstSibling) {
const left = atom.leftSibling;
if (!left) break;
atom = left;
if (atom.type === 'mclose') depth++;
else if (atom.type === 'mopen' && --depth === 0) break;
}
if (!atom.isFirstSibling) {
const left = atom.leftSibling;
Expand Down
109 changes: 109 additions & 0 deletions test/playwright-tests/physical-keyboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,115 @@ async function shiftTab(page) {
await page.waitForTimeout(500);
}

for (const {
input,
start = 0,
end,
template,
expected,
reloaded = expected,
selectedOnly = false,
} of [
{
input: '1+(a,[b,c))',
start: 2,
end: 11,
template: String.raw`\frac{#@}{2}`,
expected: String.raw`1+\frac{(a,[b,c))}{2}`,
},
{
input: '(a,b]',
end: 5,
template: String.raw`\frac{#@}{2}`,
expected: String.raw`\frac{(a,b]}{2}`,
},
{
input: 'red^3',
end: 3,
template: String.raw`\textcolor{#0}{x}^2`,
expected: String.raw`{\textcolor{red}{x}^2}^3`,
reloaded: '{x^2}^3',
},
{ input: '2^3', end: 1, template: '#@^2', expected: '{2^2}^3' },
{ input: 'a_2', end: 1, template: '#@_3', expected: '{a_3}_2' },
{ input: '2^3', end: 1, template: 'a+#@^2', expected: 'a+{2^2}^3' },
{
input: 'a_2',
end: 1,
template: '#@^3',
expected: 'a^3_2',
reloaded: 'a_2^3',
},
{
input: '12^3',
end: 2,
template: 'a+z^2',
expected: 'a+{z^2}^3',
selectedOnly: true,
},
{ input: '12^3', end: 2, template: '#@+#@^2', expected: '12+{12^2}^3' },
{ input: '12^3', end: 2, template: '#@^2', expected: '{12^2}^3' },
{ input: '(x+1)^2', end: 5, template: '#@^2', expected: '{(x+1)^2}^2' },
{ input: '12^3', end: 2, template: 'a+#@^2', expected: 'a+{12^2}^3' },
{ input: '(x+1)^2', end: 5, template: 'a+#@^2', expected: 'a+{(x+1)^2}^2' },
{ input: '12_3', end: 2, template: '#@_2', expected: '{12_2}_3' },
{ input: '12^3', end: 2, template: '(#@)^2', expected: '{(12)^2}^3' },
{ input: '(a,b]^3', end: 5, template: '#@^2', expected: '{(a,b]^2}^3' },
{ input: '12^3', end: 2, template: '#0^2', expected: '{12^2}^3' },
{ input: '1.2^3', end: 3, template: '#@^2', expected: '{1.2^2}^3' },
{
input: '(x+(y+1))^2',
end: 9,
template: '#@^2',
expected: '{(x+(y+1))^2}^2',
},
{
input: '12_3',
end: 2,
template: '#@^2',
expected: '12^2_3',
reloaded: '12_3^2',
},
]) {
for (const implicit of selectedOnly || template.includes('#0')
? [false]
: [false, true]) {
test(`insertion preserves the complete ${implicit ? 'implicit' : 'selected'} base: ${input}, ${template} (#3099)`, async ({
page,
}) => {
await page.goto('/dist/playwright-test-page/');
const result = await page.locator('#mf-1').evaluate(
(mfe: MathfieldElement, { input, start, end, template, implicit }) => {
mfe.value = input;
mfe.focus();
mfe.selection = { ranges: [[implicit ? end : start, end]] };
mfe.executeCommand(['insert', template]);
const latex = mfe.getValue();
mfe.executeCommand('undo');
const undone = mfe.getValue();
mfe.executeCommand('redo');
const redone = mfe.getValue();
mfe.value = '';
mfe.value = latex;
return {
latex,
undone,
redone,
reloaded: mfe.getValue('latex-unstyled'),
};
},
{ input, start, end, template, implicit }
);
expect(result).toEqual({
latex: expected,
undone: input,
redone: expected,
reloaded,
});
});
}
}

test('lower bound is navigated before upper bound (#2966)', async ({
page,
}) => {
Expand Down
38 changes: 38 additions & 0 deletions test/playwright-tests/virtual-keyboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,44 @@ async function virtualKeyboardSample2(
return 'n=x^2';
}

for (const [base, end] of [
['2', 1],
['12', 2],
['(x+1)', 5],
] as const) {
test(`squaring the selected base ${base} preserves its superscript (#3099)`, async ({
page,
}) => {
await page.goto('/dist/playwright-test-page/');
const mf = page.locator('#mf-1');
await page.locator('.ML__virtual-keyboard-toggle').nth(0).click();
await mf.evaluate((mfe: MathfieldElement, value) => {
mfe.value = value;
mfe.focus();
}, base);

// Square once without a conflict, then square just the base again.
for (const expected of [`${base}^2`, `{${base}^2}^2`]) {
const selected = await mf.evaluate((mfe: MathfieldElement, end) => {
mfe.selection = { ranges: [[0, end]] };
return mfe.getValue(mfe.selection, 'latex');
}, end);
expect(selected).toBe(base);
await keycapLocator(page, { value: '#@^2}' }).click();
expect(await mf.evaluate((mfe: MathfieldElement) => mfe.getValue())).toBe(
expected
);
}
const reloaded = await mf.evaluate((mfe: MathfieldElement) => {
const latex = mfe.getValue();
mfe.value = '';
mfe.value = latex;
return mfe.getValue('latex-unstyled');
});
expect(reloaded).toBe(`{${base}^2}^2`);
});
}

test('virtual keyboard with two math fields', async ({ page }) => {
await page.goto('/dist/playwright-test-page/');

Expand Down