diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index db87317dc1..100ba8dab5 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2669,7 +2669,7 @@ describe('DomPainter', () => { painter.paint(emptyLayout, mount); const line = mount.querySelector('.superdoc-line'); - expect(line?.textContent).toBe('\u00A0'); + expect(line?.textContent).toBe('\u200B'); }); it('paints empty-line caret targets with the insertion run typography', () => { diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.test.ts b/packages/layout-engine/painters/dom/src/runs/render-line.test.ts index fb9ce447d1..d343e71a41 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.test.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.test.ts @@ -530,3 +530,51 @@ describe('renderLine inline boxes', () => { expect(cleared.textContent).toBe('beforeboxedafter'); }); }); + +describe('renderLine empty-line placeholder', () => { + const emptyLine = (): Line => ({ + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 0, + maxWidth: 200, + ascent: 12, + descent: 4, + lineHeight: 18, + segments: [], + }); + + const emptyBlock = (attrs: ParagraphBlock['attrs'] = {}): ParagraphBlock => ({ + kind: 'paragraph', + id: 'empty-block', + attrs, + runs: [{ kind: 'text', text: '', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 1 }], + }); + + it('fills the placeholder with a zero-width space so it takes no advance', () => { + const lineEl = renderLine({ + block: emptyBlock(), + line: emptyLine(), + context: { pageNumber: 1, totalPages: 1, section: 'body' }, + runContext: makeRunContext(), + }); + + const placeholder = lineEl.querySelector('.superdoc-empty-run'); + expect(placeholder?.textContent).toBe('\u200B'); + expect(placeholder?.dataset.pmStart).toBe('1'); + expect(placeholder?.dataset.pmEnd).toBe('1'); + }); + + it('keeps the placeholder zero-advance on an RTL line', () => { + const lineEl = renderLine({ + block: emptyBlock({ directionContext: { inlineDirection: 'rtl' } }), + line: emptyLine(), + context: { pageNumber: 1, totalPages: 1, section: 'body' }, + runContext: makeRunContext(), + }); + + expect(lineEl.getAttribute('dir')).toBe('rtl'); + expect(lineEl.querySelector('.superdoc-empty-run')?.textContent).toBe('\u200B'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index 505d89d283..7d1ef2f8cd 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -41,6 +41,20 @@ function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { return isMinimalWordLayoutShared(value); } +/** + * Filler for the placeholder span painted on an empty line. + * + * The span carries PM positions; it is not meant to occupy space, so its filler + * must take no advance width. A non-breaking space takes one. The caret on an + * empty line is drawn at the placeholder's left edge, so that width pushes the + * caret off the start of the line: invisible on an LTR line, where the left edge + * is the line start, but visible on an RTL line, which starts at the right edge. + * + * A zero-width space keeps the span's font metrics, and with them the caret's + * height, while contributing no advance. + */ +const EMPTY_LINE_PLACEHOLDER = '\u200B'; + const applyStyles = (el: HTMLElement, styles: Partial): void => { Object.entries(styles).forEach(([key, value]) => { if (value != null && value !== '' && key in el.style) { @@ -541,7 +555,7 @@ export const renderLine = ({ } else { span.style.fontSize = `${line.lineHeight}px`; } - span.innerHTML = ' '; + span.textContent = EMPTY_LINE_PLACEHOLDER; el.appendChild(span); }