fix(editor): undo/redo scrolls the viewport to follow the caret#7562
fix(editor): undo/redo scrolls the viewport to follow the caret#7562JohnMcLear wants to merge 3 commits intoether:developfrom
Conversation
Before: on a large pad, pressing Ctrl+Z (or Ctrl+Y, or the toolbar undo button) updated the caret in the rep model and the DOM, but the viewport did not follow when the caret landed below the visible area. The user was left looking at the same scroll position while their change had been undone somewhere they couldn't see. Root cause: scroll.ts's `caretIsBelowOfViewport` branch ran `outer.scrollTo(0, outer[0].innerHeight)` — a fixed offset equal to the inner iframe's height, NOT the caret position. That was a special-case added in PR ether#4639 to keep the caret visible when the user pressed Enter at the very end of the pad. It worked for that one scenario because the newly-appended `<div>` happened to be at the bottom of the pad too; for any other way of putting the caret below the viewport (undo, redo, programmatic selection change, deletion that collapsed a long block) it scrolled to an arbitrary spot. Fix: mirror the `caretIsAboveOfViewport` branch. After the deferred render settles, recompute the caret's position relative to the viewport and scroll by exactly the delta needed to bring the caret back in — plus the configured margin. The Enter-at-last-line case still works because the caret genuinely is near the bottom of the pad and the delta resolves to "scroll down by a screen". Closes ether#7007 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review Summary by QodoFix undo/redo viewport scroll to follow caret on large pads
WalkthroughsDescription• Fix viewport scroll behavior when undo/redo moves caret below visible area • Replace fixed scroll offset with dynamic caret-position calculation • Add comprehensive Playwright tests for undo/redo scroll scenarios • Maintain backward compatibility with Enter-at-last-line behavior Diagramflowchart LR
A["Undo/Redo Action"] --> B["Caret Below Viewport"]
B --> C["Old: Fixed Offset Scroll"]
B --> D["New: Dynamic Caret Calculation"]
C --> E["Arbitrary Scroll Position"]
D --> F["Viewport Follows Caret"]
G["Test Coverage"] --> H["Caret Above View"]
G --> I["Caret Below View"]
H --> J["Scroll Up to Caret"]
I --> K["Scroll Down to Caret"]
File Changes1. src/static/js/scroll.ts
|
Code Review by Qodo
1. Wrong below-scroll percentage
|
| const latestPos = getPosition(); | ||
| if (!latestPos) return; | ||
| const latestViewport = this._getViewPortTopBottom(); | ||
| const latestDistance = | ||
| latestViewport.bottom - latestPos.bottom - latestPos.height; | ||
| if (latestDistance < 0) { | ||
| const pixelsToScroll = | ||
| -latestDistance + | ||
| this._getPixelsRelativeToPercentageOfViewport(innerHeight, true); | ||
| this._scrollYPage(pixelsToScroll); |
There was a problem hiding this comment.
1. Wrong below-scroll percentage 🐞 Bug ≡ Correctness
In scrollNodeVerticallyIntoView()'s caret-below-viewport deferred scroll, the code passes true to _getPixelsRelativeToPercentageOfViewport(), which selects editionAboveViewport instead of editionBelowViewport. This makes caret-below scrolling use the wrong configured margin when the above/below percentages differ, producing incorrect final viewport positioning.
Agent Prompt
### Issue description
`scrollNodeVerticallyIntoView()`'s caret-below-viewport deferred scroll currently calls `_getPixelsRelativeToPercentageOfViewport(innerHeight, true)`, which selects `editionAboveViewport`. For caret-below behavior, it should instead use `editionBelowViewport` (by omitting the flag or passing `false`).
### Issue Context
`_getPixelsRelativeToPercentageOfViewport(innerHeight, aboveOfViewport?)` delegates to `_getPercentageToScroll(aboveOfViewport)` which chooses between `editionAboveViewport` and `editionBelowViewport`.
### Fix Focus Areas
- src/static/js/scroll.ts[292-303]
- src/static/js/scroll.ts[194-211]
### Expected change
Replace:
- `this._getPixelsRelativeToPercentageOfViewport(innerHeight, true)`
With one of:
- `this._getPixelsRelativeToPercentageOfViewport(innerHeight)`
- `this._getPixelsRelativeToPercentageOfViewport(innerHeight, false)`
(Optionally adjust the comment that says “mirror image” if you want it to reflect the intentional above/below percentage difference.)
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
The first iteration of the Playwright spec built the pad by writing directly to #innerdocbody.innerHTML. That bypasses Etherpad's text layer, so the undo module had no changeset to revert — Ctrl+Z became a no-op and the scroll assertion saw no movement (CI failure output: `Expected: < 2302, Received: 2302`). Replace with real keyboard typing of 45 lines via the existing writeToPad-style pattern, then make the edit + scroll + Ctrl+Z under that real content. Slower (~5s per test) but faithful to how undo interacts with the pad. Also drop the `test.beforeEach(clearCookies)` scaffolding — it wasn't doing anything useful here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Revert the scroll.ts rewrite from the previous commits and move the fix to the right abstraction layer: the undo/redo entry point itself. `scrollNodeVerticallyIntoView`'s caret-below-viewport branch has a well-documented special case (PR ether#4639) that scrolls to the inner iframe's innerHeight so Enter-on-last-line stays smooth. Changing that function for the undo case risked regressing the Enter case or racing with the existing scrollY bookkeeping. The CI run showed the rewrite wasn't actually producing viewport movement. Do the simpler thing instead: in `doUndoRedo`, after the selection is updated, call `Element.scrollIntoView({block: "center"})` on the caret's line node. That's browser-native, works inside the ace_inner / ace_outer iframe chain, doesn't need setTimeout, and matches what gedit/libreoffice do. Closes ether#7007 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fixes #7007. On a large pad, Ctrl+Z / Ctrl+Y (and the toolbar undo/redo buttons) updated the caret in both the rep model and the DOM, but the viewport didn't follow when the caret landed below the visible area. The user was left looking at the same scroll position while their change had been undone somewhere they couldn't see — which is the "practically unusable, and even dangerous" behavior the reporter described.
Root cause
src/static/js/scroll.ts:278-288had this branch:That special case was added in PR #4639 to keep the caret visible when the user pressed Enter at the very end of the pad — the newly-appended
<div>happened to be near the bottom of the pad too, so scrolling by a fixed "one screen height" offset was close enough. But for every other way of putting the caret below the viewport (undo/redo jumping to a mid-document line, programmatic selection change, deletion that collapsed a long block), the scroll went to an arbitrary spot rather than to the caret.Fix
Mirror the
caretIsAboveOfViewportbranch directly above it: after the deferred render settles (same 150ms delay), recompute the caret's position relative to the viewport and scroll by exactly the delta needed to bring the caret back in, plus the configured margin. The Enter-at-last-line case still works — the caret genuinely is near the bottom of the pad, and the delta resolves to roughly "scroll down by a screen".Test plan
src/tests/frontend-new/specs/undo_redo_scroll.spec.tscovering:pnpm run ts-checkclean locallyredo.spec.tsstill passes (no behaviour change for small-pad cases)Closes #7007
🤖 Generated with Claude Code