fix(muya): give a code block's trailing empty line a line box by Jocs · Pull Request #5286 · marktext/marktext · GitHub
Skip to content

fix(muya): give a code block's trailing empty line a line box - #5286

Merged
Jocs merged 5 commits into
developfrom
worktree-glowing-bouncing-spring
Sep 10, 2026
Merged

Jocs merged 5 commits into
developfrom
worktree-glowing-bouncing-spring

Conversation

@Jocs

@Jocs Jocs commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Fixes #5114

In a code block, type a line and press Enter twice: the caret should be on line 3, but it stays on line 2. While testing this branch a second defect on the same empty lines turned up — arrow keys there left the code block — and it is fixed here too.

Root cause

A newline with nothing after it lays out no line. The code block compensated by wrapping its final \n in a display: block mu-line-end span (getHighlightHtml(…, handleLineEnding)), and that only works when the text before the newline does not end in one as well. Measured in Chromium:

text rendered lines drawn expected
hello\n hello<span.mu-line-end>\n</span> 2 2
hello\n\n hello\n<span.mu-line-end>\n</span> 2 3
\n (empty block + Enter) <span.mu-line-end>\n</span> 1 2

With no line box to sit on, the caret is drawn on the line above. The same gap made ArrowDown onto the empty last line type at the end of the previous line: hello\nabc + ArrowDown + q gave hello\nabcq\n.

Fix

CodeBlockContent.update() appends <span class="mu-trailing-break"><br></span> whenever the text ends with a newline. The text node — and so textContent and every offset — is unchanged. Two constraints shape it:

  • Added after highlighting. Prism's keep-markup plugin re-inserts preserved elements by text position and never matches a zero-width element at the very end of the text, so a <br> in the pre-highlight HTML is dropped.
  • Wrapped in a span. Chromium puts the caret just before the <br>, inside its parent. For a bare <br> that is (content, 1), which TextSelection.getSelection reads as text offset 1 — clicking the last line and typing produced hzello. Inside the wrapper the caret is at (wrapper, 0), which resolves to the text length.

The IME composition anchor from #5281 is now seeded ahead of the trailing break; appended after it, the composition started a line below the caret.

getHighlightHtml loses its handleLineEnding path, which only the code block used.

Measured and rejected

approach lines caret
mu-line-end inline + ::after { content: '\200B' } ✓ ✗ painted a line up
bare trailing <br> ✓ ✗ click / ArrowDown onto the last line resolve to offset 1

Arrow keys on empty lines

In hello\nworld\n with the caret on the empty last line, ArrowUp left the code block for the language input instead of moving to world; from an empty middle line, ArrowDown left it the other way. This predates the branch.

Content.arrowHandler measures the caret to decide whether it is on the block's first or last line. A caret on an empty line has no client rect, and the failed measurement reads as both. In a code block a newline on the caret's side already answers the question, so CodeBlockContent.arrowHandler leaves those keys to the browser and defers to Content.arrowHandler otherwise — including a wrapped first or last line, which still needs the measurement.

Tests

  • e2e/tests/blocks/codeblock-trailing-newline-5114.spec.ts (Chromium)
    • lines drawn and the line the caret is painted on after Enter ×2, after Enter in an empty block, and in a Prism-highlighted block. A collapsed range right after a newline has no client rects, so the caret is found in a screenshot: glyphs transparent, caret painted red, caret-animation: manual;
    • click and ArrowDown onto the empty last line, then type;
    • ArrowUp from the empty last line and ArrowDown from an empty middle line stay in the block; ArrowUp on the first line still leaves it;
    • an IME composition on the empty last line via CDP Input.imeSetComposition.
  • codeBlockContent/__tests__/trailingNewline.spec.ts (jsdom) — a trailing break only after a final newline, textContent unchanged, kept on highlighted blocks, the IME anchor seeded ahead of it.
  • codeBlockContent/__tests__/arrowHandler.spec.ts (happy-dom, which like Chromium on an empty line gives the caret no rect) — which arrow keys are left to the browser and which still go through Content.arrowHandler.

Verified

before this branch
E2E, trailing-line cases (6) develop: 4 fail 6 pass; 48/48 at --repeat-each=8 --workers=4
E2E, arrow-key cases (3) before the arrow fix: 2 fail 3 pass; 24/24 at --repeat-each=8 --workers=4
unit, trailingNewline.spec.ts (4) develop: 3 fail 4 pass
unit, arrowHandler.spec.ts (4) Content.arrowHandler prevents both empty-line moves 4 pass

Both IME guards fail with the anchor change reverted. tsc --noEmit and ESLint (0 errors) are clean, and the codeBlockContent / base arrow-navigation / table-cell arrow / HTML-block arrow unit suites pass. Locally the blocks, diagrams, typing and options E2E directories pass except search-replace ×2 and the ime.spec table-cell case — which fail identically on unmodified develop in the same setup — and the first two options/autopair cases, which type into a paragraph and never reach this code.

Found along the way, not changed here

  • A new code block can drop the caret of the first keystroke. With code-block line numbers enabled (off by default), a new code block re-renders on its first animation frame to fill the gutter (codeBlock/index.ts). A keystroke landing before that frame loses its caret to the start of the block, and the rest of the word is typed in front of it: hello becomes elloh. Seen only in automation under heavy parallel load (22 of 32 runs at 4 workers); not reproduced at typing speed, even with 20× CPU throttling. The E2E spec waits a frame after creating a block.
  • TextSelection.getSelection reads an element-level boundary (content, n) as text offset n rather than the length of the text before child n. No normal editing path on develop is known to produce such a boundary — the bare <br> above did. To see it: in a code block containing hello world, set the selection to (codeContent, 1) (the end of the text), press Shift, then type Z — the result is hZello world.

🤖 Generated with Claude Code

https://claude.ai/code/session_013QodFHUNmHeC8NhrHSYeXk

Jocs and others added 5 commits September 10, 2026 19:37
A newline with nothing after it lays out no line. The code block wrapped
its final newline in a display:block `mu-line-end` span, which only makes
up for that when the text before the newline does not end in one too:
`hello\n\n` drew two lines, so Enter twice left the caret on the second
line, and Enter in an empty code block did not move the caret at all.

Append `<span class="mu-trailing-break"><br></span>` after highlighting
whenever the text ends with a newline. It goes in after Prism runs because
keep-markup drops an empty element at the very end of the text, and the
<br> is wrapped because Chromium puts the caret just before it: a bare
<br> yields (content, 1), which the offset mapping reads as text offset 1.

Seed the IME composition anchor ahead of the trailing break; appended
after it, the composition starts a line below the caret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QodFHUNmHeC8NhrHSYeXk
)

Line boxes and caret painting only exist in a real browser, so the spec
measures them in Chromium. A collapsed range right after a newline has no
client rects, so the caret is located in a screenshot instead: glyphs
hidden, caret painted red with `caret-animation: manual`.

It also guards the ways the fix can regress: a click or ArrowDown onto the
empty last line must type at the end of the code, and an IME composition
there must stay on that line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QodFHUNmHeC8NhrHSYeXk
The code block, its only caller, no longer asks for the `mu-line-end`
wrapper. The removed branch also sliced the highlighted slice with
offsets into the whole text (`highlightContent.substring(start, end - 1)`),
losing highlighted text whenever the highlight did not start at offset 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QodFHUNmHeC8NhrHSYeXk
Content.arrowHandler decides whether the caret is on a block's first or
last line by measuring it, and a caret on an empty line has no client
rect. The failed measurement read as both first and last line, so ArrowUp
from the empty last line, or ArrowDown from an empty middle line, left the
code block. In a code block a newline on the caret's side already answers
the question, so those keys are left to the browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QodFHUNmHeC8NhrHSYeXk
ArrowUp from the empty last line and ArrowDown from an empty middle line
must move within the block; ArrowUp on the first line must still leave it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QodFHUNmHeC8NhrHSYeXk
@Jocs
Jocs merged commit 19faab1 into develop Sep 10, 2026
7 checks passed
@Jocs
Jocs deleted the worktree-glowing-bouncing-spring branch September 10, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] code block 中换行不能准确表现

1 participant