Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/core/atom-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1134,7 +1134,10 @@ function getStyleRuns(atoms: readonly Atom[]): (readonly Atom[])[] {
const runs: Atom[][] = [];
let run: Atom[] = [];
for (const atom of atoms) {
if (atom.type === 'first') run.push(atom);
if (atom.type === 'first') {
run.push(atom);
continue;
}
if (!style && !atom.style) run.push(atom);
else {
const atomStyle = atom.style;
Expand Down
6 changes: 4 additions & 2 deletions src/editor-mathfield/keyboard-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,8 @@ function insertSmartFence(model: _Model, key: string, style?: Style): boolean {
// There is a selection, wrap it with the fence
model.mathfield.snapshot();
const [start, end] = range(model.selection);
const body = model.extractAtoms([start, end]);
let body = model.extractAtoms([start, end]);
body = body.filter((a) => a.type !== 'first');
const atom = parent!.addChildrenAfter(
[
new LeftRightAtom('left...right', body, {
Expand Down Expand Up @@ -1074,11 +1075,12 @@ function insertSmartFence(model: _Model, key: string, style?: Style): boolean {
if (sibling) {
model.mathfield.snapshot();
// We've found a matching sibling
const body = model.extractAtoms([
let body = model.extractAtoms([
model.offsetOf(atom),
model.offsetOf(sibling),
]);
body.pop();
body = body.filter((a) => a.type !== 'first');
const newLeftRight = new LeftRightAtom('left...right', body, {
leftDelim: fence,
rightDelim: rDelim,
Expand Down
85 changes: 65 additions & 20 deletions src/editor-mathfield/mode-editor-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,15 @@ export class MathModeEditor extends ModeEditor {
//
// Delete any placeholders before or after the insertion point
//
const currentAtom = model.at(model.position);
if (
!model.at(model.position).isLastSibling &&
model.at(model.position + 1).type === 'placeholder'
currentAtom &&
!currentAtom.isLastSibling &&
model.at(model.position + 1)?.type === 'placeholder'
) {
// Before a `placeholder`
model.deleteAtoms([model.position, model.position + 1]);
} else if (model.at(model.position).type === 'placeholder') {
} else if (currentAtom?.type === 'placeholder') {
// After a `placeholder`
model.deleteAtoms([model.position - 1, model.position]);
}
Expand Down Expand Up @@ -270,13 +272,13 @@ export class MathModeEditor extends ModeEditor {
const insertingFraction =
newAtoms.length === 1 && newAtoms[0].type === 'genfrac';

const atomAtPos = model.at(model.position);
if (
insertingFraction &&
implicitArgumentOffset >= 0 &&
typeof model.mathfield.options.isImplicitFunction === 'function' &&
model.mathfield.options.isImplicitFunction(
model.at(model.position).command
)
atomAtPos &&
model.mathfield.options.isImplicitFunction(atomAtPos.command)
) {
// If this is a fraction, and the implicit argument is a function,
// try again, but without the implicit argument
Expand All @@ -290,19 +292,21 @@ export class MathModeEditor extends ModeEditor {
argFunction,
options
);
} else if (implicitArgumentOffset >= 0) {
// Remove implicit argument
model.deleteAtoms([implicitArgumentOffset, model.position]);
}
} // Implicit argument deletion is deferred until after insertion:
// deleting before insertion empties the field and breaks cursor positioning (#2974)

//
// 3/ Insert the new atoms
//

// Save the implicit argument range before insertion (in case model.position changes)
const implicitArgEndOffset = model.position;

if (newAtoms.length === 1 && newAtoms[0].isRoot) model.root = newAtoms[0];
else {
const { parent } = model.at(model.position);
const hadEmptyBody = parent!.hasEmptyBranch('body');
const atom = model.at(model.position);
const parent = atom?.parent ?? model.root;
const hadEmptyBody = parent.hasEmptyBranch('body');

// Are we inserting a fraction inside a leftright?
if (
Expand All @@ -322,7 +326,34 @@ export class MathModeEditor extends ModeEditor {
}

const cursor = model.at(model.position);
cursor.parent!.addChildrenAfter(newAtoms, cursor);
if (cursor) {
cursor.parent!.addChildrenAfter(newAtoms, cursor);
} else {
// cursor can be null when deferred implicit arg deletion
// leaves model.position pointing past the last atom
const body = parent.branch('body');
const firstAtom = body?.[0];
if (firstAtom) {
parent.addChildrenAfter(newAtoms, firstAtom);
} else {
parent.setChildren(newAtoms, 'body');
}
}

if (implicitArgumentOffset >= 0) {
model.deleteAtoms([implicitArgumentOffset, implicitArgEndOffset]);

// deleteAtoms can leave "first" atoms pointing to removed parents
const rootBody = model.root.branch('body');
if (rootBody) {
for (const child of rootBody) {
if (child.parent !== model.root) {
child.parent = model.root;
child.parentBranch = 'body';
}
}
}
}

if (format === 'latex' && typeof input === 'string') {
// If we are given a latex string with no arguments, store it as
Expand Down Expand Up @@ -505,6 +536,8 @@ function removeExtraneousParenthesis(atom: Atom): Atom {
*/
function getImplicitArgOffset(model: _Model): Offset {
let atom = model.at(model.position);
if (!atom) return -1;

if (atom.mode === 'text') {
while (!atom.isFirstSibling && atom.mode === 'text')
atom = atom.leftSibling;
Expand All @@ -523,18 +556,28 @@ function getImplicitArgOffset(model: _Model): Offset {
while (
!atom.isFirstSibling &&
!(atom.type === 'mopen' && atom.value === delim)
)
atom = atom.leftSibling;
if (!atom.isFirstSibling) atom = atom.leftSibling;
) {
const left = atom.leftSibling;
if (!left) break;
atom = left;
}
if (!atom.isFirstSibling) {
const left = atom.leftSibling;
if (left) atom = left;
}
afterDelim = true;
} else if (atom.type === 'leftright') {
atom = atom.leftSibling;
const left = atom.leftSibling;
if (left) atom = left;
afterDelim = true;
}

if (afterDelim) {
while (!atom.isFirstSibling && (atom.isFunction || isImplicitArg(atom)))
atom = atom.leftSibling;
while (!atom.isFirstSibling && (atom.isFunction || isImplicitArg(atom))) {
const left = atom.leftSibling;
if (!left) break;
atom = left;
}
} else {
const delimiterStack: string[] = [];

Expand All @@ -551,7 +594,9 @@ function getImplicitArgOffset(model: _Model): Offset {
)
delimiterStack.shift();

atom = atom.leftSibling;
const left = atom.leftSibling;
if (!left) break;
atom = left;
}
}

Expand Down
168 changes: 168 additions & 0 deletions test/playwright-tests/physical-keyboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,174 @@ test('backspace should not trap caret in empty latex group', async ({ page }) =>
expect(latex).toBe('');
});

test('no phantom caret after inserting left paren', async ({ page }) => {
// Regression: getStyleRuns was pushing "first" atoms twice into the same run,
// causing duplicate ML__caret spans in the rendered output
await page.goto('/dist/playwright-test-page/');
const mf = page.locator('#mf-1');

await mf.pressSequentially('5');
await mf.pressSequentially(')');
await mf.press('Home');
expect(await mf.locator('.ML__caret').count()).toBe(1);

await mf.pressSequentially('(');
expect(await mf.locator('.ML__caret').count()).toBe(1);
});

test('fraction after parenthesized expression', async ({ page }) => {
// Regression test: inserting fraction after parenthesized expression
// Steps: type 5, add ), Home, add (, End, press /
await page.goto('/dist/playwright-test-page/');

const mf = page.locator('#mf-1');

await mf.pressSequentially('5');
await mf.pressSequentially(')');
await mf.press('Home');
await mf.pressSequentially('(');
await mf.press('End');
await mf.pressSequentially('/');

const latex = await mf.evaluate((mfe: MathfieldElement) => {
return mfe.value;
});

expect(latex).toBeTruthy();
});

test('fraction after parenthesized expression then ctrl+a delete retype', async ({ page }) => {
// Regression test for issue #2974: after building a fraction via the
// keyboard sequence 5 ) Home ( End /, then Ctrl+A/Delete, typing should
// still work (no orphaned parent references in the model)
await page.goto('/dist/playwright-test-page/');
const mf = page.locator('#mf-1');

// Build the fraction through the problematic keyboard sequence
await mf.pressSequentially('5');
await mf.pressSequentially(')');
await mf.press('Home');
await mf.pressSequentially('(');
await mf.press('End');
await mf.pressSequentially('/');

// Verify the fraction was created
let latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('\\frac{\\left(5\\right)}{\\placeholder{}}');

// Select all and delete
await mf.focus();
await mf.press('Control+a');

const hasSelection = await mf.evaluate((mfe: MathfieldElement) => {
const model = (mfe as any)._mathfield.model;
return model.anchor !== model.position;
});

if (!hasSelection) return; // Ctrl+A not supported in this browser

await mf.press('Delete');

latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('');

// Field should still accept input
await mf.pressSequentially('42');
latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('42');
});

test('fraction after parenthesized expression then more input', async ({ page }) => {
// Core regression test for issue #2974: fraction insertion should work
await page.goto('/dist/playwright-test-page/');

const mf = page.locator('#mf-1');

// Create the fraction after parentheses (the problematic case from #2974)
await mf.pressSequentially('5');
await mf.pressSequentially(')');
await mf.press('Home');
await mf.pressSequentially('(');
await mf.press('End');
await mf.pressSequentially('/');

// Verify the fraction was created correctly (not empty)
let latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('\\frac{\\left(5\\right)}{\\placeholder{}}');

// Verify field is still responsive by typing more content
// Move to the end and type more
await mf.press('End');
await mf.pressSequentially('+1');
latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('\\frac{\\left(5\\right)}{\\placeholder{}}+1');
});

test('fraction after complex parenthesized expression', async ({ page }) => {
// Regression test: ensure field remains editable after fraction insertion
await page.goto('/dist/playwright-test-page/');

const mf = page.locator('#mf-1');

await mf.pressSequentially('3');
await mf.pressSequentially('+');
await mf.pressSequentially('4');
await mf.pressSequentially(')');
await mf.press('Home');
await mf.pressSequentially('(');
await mf.press('End');
await mf.pressSequentially('/');

const latexAfterFraction = await mf.evaluate((mfe: MathfieldElement) => {
return mfe.value;
});

expect(latexAfterFraction).toBeTruthy();

// Verify we can continue editing
await mf.pressSequentially('2');

const final = await mf.evaluate((mfe: MathfieldElement) => {
return mfe.value;
});

expect(final).toBeTruthy();
// The field should still be responsive and editable (the '2' may go into a placeholder)
expect(final).not.toBe('');
});

test('select and wrap in parens then delete should not empty field (#2974)', async ({ page }) => {
// Regression: typing 1, selecting it, typing ( to wrap in parens,
// then pressing Delete should not make the mathfield empty/uninteractable
await page.goto('/dist/playwright-test-page/');
const mf = page.locator('#mf-1');

// Type 1
await mf.pressSequentially('1');

// Select the 1 with Shift+Left
await mf.press('Shift+ArrowLeft');

// Type ( to wrap selection in parentheses
await mf.pressSequentially('(');

// Verify the parenthesized expression was created
let latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('\\left(1\\right)');

// Press Delete - this should delete the selected content, not break the field
await mf.press('Delete');

latex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(latex).toBe('');

// Field should still accept input
await mf.pressSequentially('2');
const finalLatex = await mf.evaluate((mfe: MathfieldElement) => mfe.value);
expect(finalLatex).toBe('2');
});


async function tab(page) {
await page.keyboard.press('Tab');
// Wait some time for focus to change
Expand Down