Skip to content

Commit 3ad0ffc

Browse files
authored
fix(math): enable cursor positioning after inline math equations (#2616)
* fix(math): enable cursor positioning after inline math equations Two fixes: 1. renderer.ts: use minWidth/minHeight instead of fixed width/height on the sd-math wrapper so it auto-sizes to MathML content. The estimated dimensions caused overflow — clicks in the overflow area couldn't resolve to PM positions. 2. DomSelectionGeometry.ts: position caret at elRect.right (not left) when pos === pmEnd for non-text elements. This fixes cursor positioning after all atomic inline elements (math, images). SD-2402 * fix(math): use >= for caret edge detection, add regression test Address review findings: - Use pos >= entry.pmEnd instead of === to handle gap/closest-entry fallback path where pos may exceed the entry's end - Add unit test verifying caret positions at right edge of non-text elements when pos matches pmEnd SD-2402 * test(math): add behavior tests for math equation import and rendering 6 tests covering: - PM document contains mathInline/mathBlock nodes after import - DomPainter renders <math> MathML elements - Fraction renders as <mfrac> with correct numerator/denominator - Math wrapper spans have data-pm-start/end attributes - Text content preserved for unimplemented math objects - Document text labels render alongside math elements SD-2402
1 parent 88d07f6 commit 3ad0ffc

5 files changed

Lines changed: 154 additions & 3 deletions

File tree

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4739,8 +4739,9 @@ export class DomPainter {
47394739
wrapper.className = 'sd-math';
47404740
wrapper.style.display = 'inline-block';
47414741
wrapper.style.verticalAlign = 'middle';
4742-
wrapper.style.width = `${run.width}px`;
4743-
wrapper.style.height = `${run.height}px`;
4742+
// Let browser auto-size to MathML content; estimated dimensions are for layout only
4743+
wrapper.style.minWidth = `${run.width}px`;
4744+
wrapper.style.minHeight = `${run.height}px`;
47444745
wrapper.dataset.layoutEpoch = String(this.layoutEpoch ?? 0);
47454746

47464747
const mathEl = convertOmmlToMathml(run.ommlJson, this.doc);

packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,6 +1545,34 @@ describe('computeDomCaretPageLocal', () => {
15451545
y: 20,
15461546
});
15471547
});
1548+
1549+
it('positions caret at right edge for non-text nodes when pos equals pmEnd', () => {
1550+
painterHost.innerHTML = `
1551+
<div class="superdoc-page" data-page-index="0">
1552+
<div class="superdoc-line">
1553+
<img data-pm-start="1" data-pm-end="2" />
1554+
</div>
1555+
</div>
1556+
`;
1557+
1558+
domPositionIndex.rebuild(painterHost);
1559+
1560+
const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement;
1561+
const imgEl = painterHost.querySelector('img') as HTMLElement;
1562+
1563+
pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792));
1564+
imgEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 100));
1565+
1566+
const options = createCaretOptions();
1567+
const caret = computeDomCaretPageLocal(options, 2);
1568+
1569+
expect(caret).not.toBe(null);
1570+
expect(caret).toMatchObject({
1571+
pageIndex: 0,
1572+
x: 110, // elRect.right (10 + 100) - pageRect.left (0)
1573+
y: 20,
1574+
});
1575+
});
15481576
});
15491577

15501578
describe('index rebuild for disconnected elements', () => {

packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,9 +580,12 @@ export function computeDomCaretPageLocal(
580580
const textNode = targetEl.firstChild;
581581
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
582582
const elRect = targetEl.getBoundingClientRect();
583+
// For non-text elements (images, math), position caret at the right edge
584+
// when pos matches pmEnd (cursor after the element)
585+
const atEnd = pos >= entry.pmEnd;
583586
return {
584587
pageIndex: Number(page.dataset.pageIndex ?? '0'),
585-
x: (elRect.left - pageRect.left) / zoom,
588+
x: ((atEnd ? elRect.right : elRect.left) - pageRect.left) / zoom,
586589
y: (elRect.top - pageRect.top) / zoom,
587590
};
588591
}
14.2 KB
Binary file not shown.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import path from 'node:path';
2+
import { fileURLToPath } from 'node:url';
3+
import { test, expect } from '../../fixtures/superdoc.js';
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
6+
const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx');
7+
// Single-object test docs are used for focused verification by community contributors.
8+
// The all-objects doc is used for behavior tests since it exercises the full pipeline.
9+
10+
test.use({ config: { toolbar: 'none', comments: 'off' } });
11+
12+
test.describe('math equation import and rendering', () => {
13+
test('imports inline and block math nodes from docx', async ({ superdoc }) => {
14+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
15+
await superdoc.waitForStable();
16+
17+
// Verify math nodes exist in the PM document
18+
const mathNodeCount = await superdoc.page.evaluate(() => {
19+
const view = (window as any).editor?.view;
20+
if (!view) return 0;
21+
let count = 0;
22+
view.state.doc.descendants((node: any) => {
23+
if (node.type.name === 'mathInline' || node.type.name === 'mathBlock') count++;
24+
});
25+
return count;
26+
});
27+
28+
expect(mathNodeCount).toBeGreaterThan(0);
29+
});
30+
31+
test('renders MathML elements in the DOM', async ({ superdoc }) => {
32+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
33+
await superdoc.waitForStable();
34+
35+
// Verify <math> elements are rendered by the DomPainter
36+
const mathElementCount = await superdoc.page.evaluate(() => {
37+
return document.querySelectorAll('math').length;
38+
});
39+
40+
expect(mathElementCount).toBeGreaterThan(0);
41+
});
42+
43+
test('renders fraction as <mfrac> with numerator and denominator', async ({ superdoc }) => {
44+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
45+
await superdoc.waitForStable();
46+
47+
// The test doc has a display fraction (a/b) — should render as <mfrac>
48+
const fractionData = await superdoc.page.evaluate(() => {
49+
const mfrac = document.querySelector('mfrac');
50+
if (!mfrac) return null;
51+
return {
52+
childCount: mfrac.children.length,
53+
numerator: mfrac.children[0]?.textContent,
54+
denominator: mfrac.children[1]?.textContent,
55+
};
56+
});
57+
58+
expect(fractionData).not.toBeNull();
59+
expect(fractionData!.childCount).toBe(2);
60+
expect(fractionData!.numerator).toBe('a');
61+
expect(fractionData!.denominator).toBe('b');
62+
});
63+
64+
test('math wrapper spans have PM position attributes', async ({ superdoc }) => {
65+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
66+
await superdoc.waitForStable();
67+
68+
// Verify sd-math elements have data-pm-start and data-pm-end
69+
const mathSpanData = await superdoc.page.evaluate(() => {
70+
const spans = document.querySelectorAll('.sd-math');
71+
return Array.from(spans).map((el) => ({
72+
hasPmStart: el.hasAttribute('data-pm-start'),
73+
hasPmEnd: el.hasAttribute('data-pm-end'),
74+
hasLayoutEpoch: el.hasAttribute('data-layout-epoch'),
75+
}));
76+
});
77+
78+
expect(mathSpanData.length).toBeGreaterThan(0);
79+
for (const span of mathSpanData) {
80+
expect(span.hasPmStart).toBe(true);
81+
expect(span.hasPmEnd).toBe(true);
82+
expect(span.hasLayoutEpoch).toBe(true);
83+
}
84+
});
85+
86+
test('math text content is preserved for unimplemented objects', async ({ superdoc }) => {
87+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
88+
await superdoc.waitForStable();
89+
90+
// Unimplemented math objects (e.g., superscript, radical) should still
91+
// have their text content accessible in the PM document
92+
const mathTexts = await superdoc.page.evaluate(() => {
93+
const view = (window as any).editor?.view;
94+
if (!view) return [];
95+
const texts: string[] = [];
96+
view.state.doc.descendants((node: any) => {
97+
if (node.type.name === 'mathInline' && node.attrs?.textContent) {
98+
texts.push(node.attrs.textContent);
99+
}
100+
});
101+
return texts;
102+
});
103+
104+
// Should have multiple inline math nodes with text content
105+
expect(mathTexts.length).toBeGreaterThan(0);
106+
// The first inline math should be E=mc2
107+
expect(mathTexts).toContain('E=mc2');
108+
});
109+
110+
test('document text labels render alongside math elements', async ({ superdoc }) => {
111+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
112+
await superdoc.waitForStable();
113+
114+
// The labels (e.g., "1. Inline E=mc2:") should be visible
115+
await superdoc.assertTextContains('Inline E=mc2');
116+
await superdoc.assertTextContains('Display fraction');
117+
await superdoc.assertTextContains('Superscript');
118+
});
119+
});

0 commit comments

Comments
 (0)