Skip to content

fix: keep block gutters only for the blocks in view - #677

Merged
Azganoth merged 2 commits into
mainfrom
bug/block-gutter-layout
Oct 10, 2026
Merged

Azganoth merged 2 commits into
mainfrom
bug/block-gutter-layout

Conversation

@Azganoth

Copy link
Copy Markdown
Owner

Summary

Every edit rebuilt a gutter for every selectable block and positioned each one by reading the block's layout right after writing the previous gutter's styles, so a keystroke forced one document layout per block. A node decoration on every block also rewrote a position attribute on each block after the edit and built its decoration set at a cost of blocks times decorations. Typing therefore slowed with the square of the document's size: 3.4 s per character at 500 KB.

  • Gutters now exist only for blocks in the document view. Blocks start in document order, so the first block starting in view is found by bisection; the only blocks above it that can reach into view are its predecessor and that block's ancestors, so a list item or blockquote straddling the top edge keeps its gutter. Folded blocks have no layout and are passed over. Scrolling adds and drops gutters, reusing a gutter while its block stays in view.
  • A positioning pass reads all block geometry, the surface, and computed styles before writing anything, then reads the gutter rects once. A keystroke now forces a fixed number of layouts however long the document is.
  • Only selected blocks carry a decoration. Blocks no longer carry data-leafdown-block-pos; hover and drop find their block through a map of the elements in view, and the gutter and handle keep the position. Only the blocks in view are watched for resizing, and the selectable-block traversal is computed once per document state.
  • Gutter selection, hover, insertion, clipping to the document view (Block controls render outside the editor card #657), and inertness under open menus (Insertion gutter reacts to pointer input through open menus #658) keep their behavior. A block wholly outside the view now has no gutter where before it had a hidden one, which nothing could reach.

The E2E specs used the removed block attribute to pair a handle with its block. They now read the position from ProseMirror's pmViewDesc on the rendered element, an internal that is stable but undocumented. The block-selection fixture is taller than the E2E window, so its last three list items have no gutter at the default size: getGeometryForText scrolls a block into view before reading its handle, list-item counts and order come from the document, and the list-item spacing check measures the items directly.

Related Issue

Closes #670

Verification

  • pnpm exec vitest run src/features/editor/plugins/blockSelectionInteraction.test.ts: a gutter appears as its block scrolls into view and is dropped once it leaves; a container reaching into view from above keeps a gutter fitted to its visible part while a child above the view has none; an edit to a 400-block document with a folded section reads a bounded number of block rects, rebuilds only the gutters in view, and leaves no decoration on any block. These tests fail against the previous plugin. The clipping test now expects no gutter, rather than a hidden one, for blocks wholly outside the view.
  • pnpm check:frontend passed.

Manually verified in an isolated Windows 11 release build driven over the WebView2 DevTools protocol, with documents built by concatenating README.md, CONTRIBUTING.md, docs/*.md, and docs/help/*.md to each size. Keystroke time is from key down to the next paint after typing at the end of a paragraph near the middle, the median of five keystrokes on this branch and three on main; layouts are counted in a trace of one keystroke; open time is from choosing the recent file to the end of the last long frame, one open each. Same session, same machine:

main (7cea7b9) This branch
500 KB keystroke 3,408 ms 72 ms
500 KB layouts per keystroke 2,735 4
500 KB open 5.3 s 4.3 s
1 MB keystroke 18,104 ms 80 ms
1 MB layouts per keystroke 5,825 4
1 MB open 20.3 s 7.8 s
1 MB page elements 45,061 21,813
  1. In the same build, the block-selection E2E fixture showed gutters only for the blocks in view at 800×600, 640×720, and 640×400.

Not verified: the desktop E2E specs were adapted without running them locally; CI runs them.

Notes

About 200 ms after each keystroke, a 270–580 ms frame remains at 500 KB. It is the debounced whole-document serialization, with no forced layout, which #672 covers. Opening 1 MB still exceeds the 3 s budget #638 proposes, and the gutters no longer account for that time.

@Azganoth
Azganoth merged commit 958ff16 into main Oct 10, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/block-gutter-layout branch October 10, 2026 19:23
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Leafdown Oct 10, 2026
Azganoth added a commit that referenced this pull request Oct 10, 2026
## Summary

On every document change, `prosemirror-highlight` 0.15.3 (under
`@milkdown/plugin-highlight`) gathered every code block's tokens and
built one new `DecorationSet` from them, which costs top-level blocks
times tokens. Typing anywhere in a document with much code slowed with
the square of its code.

- A Leafdown plugin replaces the package's plugin and keeps the same
Shiki parser, so the decorations it produces are unchanged. It
highlights every code block when the document opens; after that, each
transaction maps the existing set and highlights again only the code
blocks in its changed ranges. An edit outside code does no highlighting
work.
- Two cases need more than the changed ranges. A node whose own opening
or closing token changed, such as a new quote wrapped around blocks, has
every code block inside it highlighted again, because mapping drops
their block decorations once they fail validation under the new parent.
An attribute step maps no positions, so the node it changes is added as
a range.
- `@milkdown/plugin-highlight` stays as the source of the Shiki
`createParser`.
- `docs/architecture.md` describes the incremental highlighting, and
`CHANGELOG.md` records the faster typing under Fixed.

## Related Issue

Closes #673

## Verification

- Focused tests compare the plugin's decorations with a full rebuild
after edits inside, between, and across code blocks, splits and joins,
language changes through `setNodeMarkup` and `setNodeAttribute`,
conversions between code and paragraph, wrapping in and lifting out of a
quote, multi-step transactions, and whole-document replacement, and they
assert which blocks the parser runs for. Disabling the wrapped-node
rule, the removal of stale decorations, or the attribute-step range each
makes them fail.
- `pnpm check:frontend` passed.

Manually verified in release builds on Windows 11, under a separate
application identity in a 1280×900 window, driven through the WebView2
DevTools protocol:

1. The fixture was 1 MB of fenced code: 930 30-line excerpts of the
repository's TypeScript, Rust, and CSS, each after a one-sentence
paragraph. Each run opened it in a fresh process and typed `x` five
times at the end of the middle top-level paragraph, then five times at
the end of the middle code block, waiting for 1.5 s without long frames
after each keystroke. Two runs of `main` at 7cea7b9 were interleaved
with two of this branch; figures are each run's median keystroke.

   | Keystroke | `main` | This branch |
   | --- | --- | --- |
   | Paragraph, key press to paint | 2.26–3.39 s | 1.64–1.78 s |
| Paragraph, long frames without forced layout | 0.76–1.08 s | 0.33–0.37
s |
   | Code block, key press to paint | 2.10–2.52 s | 1.77–1.78 s |
| Code block, long frames without forced layout | 0.79–0.96 s |
0.40–0.42 s |

2. Characters typed into the code block were highlighted with Shiki's
light and dark colours in the release build.

Not verified: opening times were not compared beyond noting they stayed
within the same noisy 12.6–19.7 s range on both builds.

## Notes

Both builds predate #677, so their paint times include more than 1 s of
forced layout per keystroke from the block gutters it since removed, and
the machine slowed by up to twice between runs. The frame time without
forced layout is the stable comparison.

`codeLineNumbers.ts` also rebuilds its whole decoration set on every
edit while code line numbers are shown. It is left unchanged here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Block gutters force a document layout per block on every edit

1 participant