From ee43a9f3124bfcbcfcc9bae275f5acb2523e511a Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Mon, 31 Aug 2026 23:40:27 +0000 Subject: [PATCH] fix(painter-dom): keep the empty-line caret on the start of RTL lines An empty line is painted with a `superdoc-empty-run` span that carries the line's PM positions. The span was filled with a non-breaking space, which has a real advance width, and the caret for an empty line is drawn at the span's left edge. On an LTR line that edge is the line start, so the space is invisible; on an RTL line the line starts at the right edge, so the caret lands one space width inside the line instead of on its start. Measured in a Hebrew document at 16px Arial: the line box ended at x=697.7 and the caret sat at x=693.3, the placeholder's left edge, 4.4px off the start. The gap scales with the font size, so it grows with zoom and larger text. Fill the placeholder with a zero-width space instead. It keeps the span's font metrics, and with them the caret's height, while contributing no advance, so the caret lands on the line start in both directions. --- .../painters/dom/src/index.test.ts | 2 +- .../painters/dom/src/runs/render-line.test.ts | 48 +++++++++++++++++++ .../painters/dom/src/runs/render-line.ts | 16 ++++++- 3 files changed, 64 insertions(+), 2 deletions(-) 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); }