diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a3ea285900..c9162c54f6 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -4739,8 +4739,9 @@ export class DomPainter { wrapper.className = 'sd-math'; wrapper.style.display = 'inline-block'; wrapper.style.verticalAlign = 'middle'; - wrapper.style.width = `${run.width}px`; - wrapper.style.height = `${run.height}px`; + // Let browser auto-size to MathML content; estimated dimensions are for layout only + wrapper.style.minWidth = `${run.width}px`; + wrapper.style.minHeight = `${run.height}px`; wrapper.dataset.layoutEpoch = String(this.layoutEpoch ?? 0); const mathEl = convertOmmlToMathml(run.ommlJson, this.doc); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts index 9e92d7487d..8ad6c28777 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -1545,6 +1545,34 @@ describe('computeDomCaretPageLocal', () => { y: 20, }); }); + + it('positions caret at right edge for non-text nodes when pos equals pmEnd', () => { + painterHost.innerHTML = ` +
+
+ +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const imgEl = painterHost.querySelector('img') as HTMLElement; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + imgEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 100)); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 2); + + expect(caret).not.toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: 110, // elRect.right (10 + 100) - pageRect.left (0) + y: 20, + }); + }); }); describe('index rebuild for disconnected elements', () => { diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index 8ac8822084..81ccc2d3a4 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -580,9 +580,12 @@ export function computeDomCaretPageLocal( const textNode = targetEl.firstChild; if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { const elRect = targetEl.getBoundingClientRect(); + // For non-text elements (images, math), position caret at the right edge + // when pos matches pmEnd (cursor after the element) + const atEnd = pos >= entry.pmEnd; return { pageIndex: Number(page.dataset.pageIndex ?? '0'), - x: (elRect.left - pageRect.left) / zoom, + x: ((atEnd ? elRect.right : elRect.left) - pageRect.left) / zoom, y: (elRect.top - pageRect.top) / zoom, }; } diff --git a/tests/behavior/tests/importing/fixtures/math-all-objects.docx b/tests/behavior/tests/importing/fixtures/math-all-objects.docx new file mode 100644 index 0000000000..22c2791447 Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-all-objects.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts new file mode 100644 index 0000000000..c3a38cc440 --- /dev/null +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx'); +// Single-object test docs are used for focused verification by community contributors. +// The all-objects doc is used for behavior tests since it exercises the full pipeline. + +test.use({ config: { toolbar: 'none', comments: 'off' } }); + +test.describe('math equation import and rendering', () => { + test('imports inline and block math nodes from docx', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // Verify math nodes exist in the PM document + const mathNodeCount = await superdoc.page.evaluate(() => { + const view = (window as any).editor?.view; + if (!view) return 0; + let count = 0; + view.state.doc.descendants((node: any) => { + if (node.type.name === 'mathInline' || node.type.name === 'mathBlock') count++; + }); + return count; + }); + + expect(mathNodeCount).toBeGreaterThan(0); + }); + + test('renders MathML elements in the DOM', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // Verify elements are rendered by the DomPainter + const mathElementCount = await superdoc.page.evaluate(() => { + return document.querySelectorAll('math').length; + }); + + expect(mathElementCount).toBeGreaterThan(0); + }); + + test('renders fraction as with numerator and denominator', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // The test doc has a display fraction (a/b) — should render as + const fractionData = await superdoc.page.evaluate(() => { + const mfrac = document.querySelector('mfrac'); + if (!mfrac) return null; + return { + childCount: mfrac.children.length, + numerator: mfrac.children[0]?.textContent, + denominator: mfrac.children[1]?.textContent, + }; + }); + + expect(fractionData).not.toBeNull(); + expect(fractionData!.childCount).toBe(2); + expect(fractionData!.numerator).toBe('a'); + expect(fractionData!.denominator).toBe('b'); + }); + + test('math wrapper spans have PM position attributes', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // Verify sd-math elements have data-pm-start and data-pm-end + const mathSpanData = await superdoc.page.evaluate(() => { + const spans = document.querySelectorAll('.sd-math'); + return Array.from(spans).map((el) => ({ + hasPmStart: el.hasAttribute('data-pm-start'), + hasPmEnd: el.hasAttribute('data-pm-end'), + hasLayoutEpoch: el.hasAttribute('data-layout-epoch'), + })); + }); + + expect(mathSpanData.length).toBeGreaterThan(0); + for (const span of mathSpanData) { + expect(span.hasPmStart).toBe(true); + expect(span.hasPmEnd).toBe(true); + expect(span.hasLayoutEpoch).toBe(true); + } + }); + + test('math text content is preserved for unimplemented objects', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // Unimplemented math objects (e.g., superscript, radical) should still + // have their text content accessible in the PM document + const mathTexts = await superdoc.page.evaluate(() => { + const view = (window as any).editor?.view; + if (!view) return []; + const texts: string[] = []; + view.state.doc.descendants((node: any) => { + if (node.type.name === 'mathInline' && node.attrs?.textContent) { + texts.push(node.attrs.textContent); + } + }); + return texts; + }); + + // Should have multiple inline math nodes with text content + expect(mathTexts.length).toBeGreaterThan(0); + // The first inline math should be E=mc2 + expect(mathTexts).toContain('E=mc2'); + }); + + test('document text labels render alongside math elements', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // The labels (e.g., "1. Inline E=mc2:") should be visible + await superdoc.assertTextContains('Inline E=mc2'); + await superdoc.assertTextContains('Display fraction'); + await superdoc.assertTextContains('Superscript'); + }); +});