From 3f3c4148f99163446abed98db5a0446dd9ea5927 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 27 Mar 2026 21:39:52 -0300 Subject: [PATCH 1/3] fix(math): enable cursor positioning after inline math equations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/layout-engine/painters/dom/src/renderer.ts | 5 +++-- .../src/editors/v1/dom-observer/DomSelectionGeometry.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c385563c6d..50c999028d 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -4677,8 +4677,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/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index 8ac8822084..6370d7d791 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, }; } From baa036dc7757cce8c198e4dbde83065ff90a7ab8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 28 Mar 2026 06:09:09 -0300 Subject: [PATCH 2/3] 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 --- .../tests/DomSelectionGeometry.test.ts | 28 +++++++++++++++++++ .../v1/dom-observer/DomSelectionGeometry.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) 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 6370d7d791..81ccc2d3a4 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -582,7 +582,7 @@ export function computeDomCaretPageLocal( 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; + const atEnd = pos >= entry.pmEnd; return { pageIndex: Number(page.dataset.pageIndex ?? '0'), x: ((atEnd ? elRect.right : elRect.left) - pageRect.left) / zoom, From f75f7b00b40c58dc4653bbe14d056ac0bbf790f5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 28 Mar 2026 06:17:28 -0300 Subject: [PATCH 3/3] test(math): add behavior tests for math equation import and rendering 6 tests covering: - PM document contains mathInline/mathBlock nodes after import - DomPainter renders MathML elements - Fraction renders as 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 --- .../importing/fixtures/math-all-objects.docx | Bin 0 -> 14491 bytes .../tests/importing/math-equations.spec.ts | 119 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/behavior/tests/importing/fixtures/math-all-objects.docx create mode 100644 tests/behavior/tests/importing/math-equations.spec.ts 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 0000000000000000000000000000000000000000..22c27914471fd2cd23ff1148f38db6ff2d9defe8 GIT binary patch literal 14491 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 b|MOT;NfrWV9>48xf&=sbXFmNLzn%RT0IdjU literal 0 HcmV?d00001 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'); + }); +});