Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
48 changes: 48 additions & 0 deletions packages/layout-engine/painters/dom/src/runs/render-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.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');
});
});
16 changes: 15 additions & 1 deletion packages/layout-engine/painters/dom/src/runs/render-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CSSStyleDeclaration>): void => {
Object.entries(styles).forEach(([key, value]) => {
if (value != null && value !== '' && key in el.style) {
Expand Down Expand Up @@ -541,7 +555,7 @@ export const renderLine = ({
} else {
span.style.fontSize = `${line.lineHeight}px`;
}
span.innerHTML = '&nbsp;';
span.textContent = EMPTY_LINE_PLACEHOLDER;
el.appendChild(span);
}

Expand Down
Loading