fix(painter-dom): keep the empty-line caret on the start of RTL lines

PA
12d ago
+64-2

Fixes #3955

The bug

Put the caret on an empty paragraph in a right-to-left document and it does not
sit on the start of the line. It sits a space width inside it.

Cause

renderLine paints an empty line with a superdoc-empty-run span that carries
the line's PM positions, and fills it with  :

// Preserve PM positions for DOM caret mapping on empty lines.
if (runsForLine.length === 0) {
  ...
  span.innerHTML = ' ';

A non-breaking space has a real advance width, and the caret for an empty line
is drawn at the placeholder's left edge. On an LTR line that edge is the line
start, so nothing shows. On an RTL line the line starts at the right edge, so
the caret ends up one space width inside the line.

Measured

A Hebrew document, Arial 16px, empty paragraph after a line of text:

x
line box right edge (= line start, RTL)697.7
.superdoc-empty-run box693.3 → 697.7
caret693.3

4.4px off the start, exactly the placeholder's width and exactly its left edge.

Two checks that it really is the placeholder's advance driving the caret, both
done from the page with CSS only, no code change:

  • .superdoc-empty-run { letter-spacing: 20px } → placeholder box widens to
    673.3 → 697.7, caret moves to 673.3. It tracks the box.
  • placeholder given zero advance → caret lands on 697.7, the line start.

The gap is proportional to the font size, so it grows with zoom and with larger
text.

Fix

Fill the placeholder with a zero-width space. It keeps the span's font metrics,
and with them the caret's height (measured: unchanged at 17px), while
contributing no advance, so the caret lands on the line start in both
directions.

Tests

  • New: renderLine empty-line placeholder in render-line.test.ts — asserts
    the zero-width filler and the preserved pmStart/pmEnd, on an LTR and an
    RTL line. Both fail on main and pass with the fix.
  • Updated: the existing empty-line placeholder assertion in index.test.ts.
  • packages/layout-engine/painters/dom suite: 61 files, 1547 tests, all pass.

One note on how I ran them: vp would not start on my machine (Windows, Node
22.20) — it fails resolving #module-sync-enabled out of vite-plus-core
before it reads any config — so I ran the painter suite with the workspace
vitest directly, using the aliases from vite.sourceResolve.ts. For the same
reason I could not run pnpm run format:check or pnpm run lint; the change
follows the surrounding style and stays well inside the line width used in the
file.