Skip to content

fix: autofocus and preserve scroll/cursor when toggling source mode#135

Merged
erictli merged 4 commits intomainfrom
fix/autofocus-source-view
Apr 2, 2026
Merged

fix: autofocus and preserve scroll/cursor when toggling source mode#135
erictli merged 4 commits intomainfrom
fix/autofocus-source-view

Conversation

@erictli
Copy link
Copy Markdown
Owner

@erictli erictli commented Apr 2, 2026

Summary

  • Automatically focuses the textarea (entering source mode) or TipTap editor (exiting source mode) so the user can keep typing without clicking
  • Preserves scroll position across view switches using block-index anchoring — finds which content block is at the viewport top and scrolls to the same block in the new view
  • Preserves cursor position using the same block-index mapping — cursor lands at the start of the same paragraph/heading in either view

Implementation

  • getMarkdownBlockOffsets() helper maps between TipTap top-level nodes and markdown blocks (blank-line separated, code-fence aware)
  • useLayoutEffect handles textarea focus/scroll immediately after DOM commit
  • requestAnimationFrame defers TipTap focus/scroll to after EditorContent remounts its ProseMirror view

Closes #117

Test plan

  • Open a long note, scroll to the middle, place cursor in a paragraph
  • Toggle source mode (Cmd+Shift+M) — verify textarea is focused, scroll is near the same content, cursor is in the same block
  • Edit something in source mode, scroll/move cursor to a different spot
  • Toggle back to formatted view — verify editor is focused, scroll and cursor land on the same block
  • Test with short notes (no scroll needed)
  • Test with code blocks and lists to verify block mapping handles them

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Preserve cursor/caret position, focus, and visible scroll location when switching between raw markdown and visual editing modes.
    • Disable word wrap in the source-mode textarea to match editor layout.
  • New Features

    • Smoother, more predictable mode transitions with synchronous restoration of focus, caret placement, and viewport alignment.

When switching between formatted and markdown source views, the editor
now automatically focuses the target view and preserves the approximate
scroll position and cursor location using block-index mapping.

Closes #117

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 06657680-c340-411d-b961-e6679bd54200

📥 Commits

Reviewing files that changed from the base of the PR and between fb515a8 and bcee74f.

📒 Files selected for processing (1)
  • src/components/editor/Editor.tsx

📝 Walkthrough

Walkthrough

Captures top-visible and cursor markdown block indices when toggling source mode, persists them in a transition ref, and restores focus, caret, and container/textarea scroll positions synchronously when entering or exiting the markdown textarea/source view. Also disables textarea word-wrap.

Changes

Cohort / File(s) Summary
Editor source-mode & focus/scroll logic
src/components/editor/Editor.tsx
Added getMarkdownBlockOffsets() and blockIndexToPos() helpers; introduced sourceModeTransitionRef; reworked toggleSourceMode() to record top/cursor block indices on enter and exit; added useLayoutEffect to synchronously restore focus, caret, and scroll; set wrap="off" on the textarea.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Editor as EditorComponent
    participant PM as ProseMirror
    participant TA as Textarea
    participant Ref as transitionRef

    User->>Editor: click toggleSourceMode()
    alt Entering source mode
        Editor->>PM: posAtCoords(top of container) -> map to topBlockIndex
        Editor->>PM: current selection -> cursorBlockIndex
        Editor->>Ref: store {topBlockIndex, cursorBlockIndex, mdSnapshot}
        Editor->>TA: set sourceContent, enable sourceMode
        TA->>TA: useLayoutEffect -> focus textarea, set caret to cursorBlockIndex, scroll to topBlockIndex
    else Exiting source mode
        Editor->>TA: read scrollTop, selectionStart -> getMarkdownBlockOffsets() -> top/cursor indices
        Editor->>Ref: store {topBlockIndex, cursorBlockIndex, mdSnapshot}
        Editor->>PM: parse markdown -> update document, disable sourceMode
        PM->>PM: requestAnimationFrame -> focus editor at blockIndexToPos(cursorBlockIndex)
        PM->>Editor: scroll container to align topBlockIndex
        PM->>Ref: clear transitionRef
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I’m a rabbit in the editor den, 🐇
I hop between source and rich again.
I mark the block where paws should be,
I set your caret and scroll with glee.
Type on — no lost focus for thee.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: autofocus behavior and scroll/cursor preservation when toggling source mode, which aligns with the primary changeset.
Linked Issues check ✅ Passed The pull request fully addresses issue #117 by implementing autofocus in both directions (formatted→source and source→formatted) as required, preventing keyboard focus loss.
Out of Scope Changes check ✅ Passed All changes directly support the linked issue objectives: autofocus behavior, scroll preservation, cursor position mapping, and markdown block offset calculations are all in-scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/autofocus-source-view

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/editor/Editor.tsx`:
- Around line 1903-1919: The current scroll restore calculates topBlockIndex
from textarea.scrollTop / lineHeight which breaks when lines wrap; replace that
logic in the block using textarea, sourceContent and getMarkdownBlockOffsets by
measuring the visual character offset via a hidden mirror element (create a div
that copies textarea styles, width and padding, set its textContent to
sourceContent up to incremental char positions or use Range measurement to map
scrollTop to the nearest char index) and use that char index to compute
topBlockIndex against blockOffsets; alternatively you can disable soft-wrap on
the textarea (wrap="off") if wrapping is unacceptable. Apply the same
mirror-based measurement or wrap="off" change to the other identical region
around the code referenced (the block at 1977-1983).
- Around line 450-478: getMarkdownBlockOffsets mis-detects fence boundaries
because inCodeFence is toggled before examining the current line and it only
starts blocks after blank lines, so headings or fence openings are missed;
update the logic in getMarkdownBlockOffsets to (1) detect a fence opener when
not inCodeFence and treat that current line as a block start (push pos) then set
inCodeFence = true, (2) when inCodeFence only toggle inCodeFence off after
checking the line so the closing fence isn’t treated as the block start, and (3)
treat ATX headings (lines starting with '#') and other top-level markers as
block starts regardless of prevBlank; adjust the checks around prevBlank,
inCodeFence, offsets, and pos in the loop to implement these rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98d826d9-e196-4c6b-a4c3-8ba0c5d87d6d

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae7a40 and 9b93e73.

📒 Files selected for processing (1)
  • src/components/editor/Editor.tsx

Comment thread src/components/editor/Editor.tsx
Comment thread src/components/editor/Editor.tsx Outdated
- Fix getMarkdownBlockOffsets: separate open/close fence handling so
  closing fences aren't mis-detected as block starts; recognize ATX
  headings as block starts regardless of preceding blank lines
- Add wrap="off" to source textarea so scrollTop/lineHeight maps
  exactly to logical lines (eliminates soft-wrap mismatch)
- Extract blockIndexToPos helper, deduplicate getMarkdownBlockOffsets
  calls in exit path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/editor/Editor.tsx`:
- Around line 2002-2022: The requestAnimationFrame scheduled when exiting source
mode (the callback that calls requestAnimationFrame and uses
editor.commands.focus, blockIndexToPos, editor.view.coordsAtPos and container)
is not cancelled which can run after unmount or after sourceMode changes; store
the RAF id when you call requestAnimationFrame and cancel it in the
component/effect cleanup (use cancelAnimationFrame(id)) and guard the callback
to ensure editor and container are still mounted/non-null before calling
editor.commands.focus or editor.view.coordsAtPos.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e00128c-38db-4183-8f14-ca85b0b26cf4

📥 Commits

Reviewing files that changed from the base of the PR and between 9b93e73 and 80d23c7.

📒 Files selected for processing (1)
  • src/components/editor/Editor.tsx

Comment thread src/components/editor/Editor.tsx Outdated
Cancel the requestAnimationFrame scheduled when exiting source mode
if the effect re-runs or the component unmounts. Guard the callback
against a disconnected editor view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/components/editor/Editor.tsx (1)

1976-1981: Inconsistent early return may cause React warnings.

Line 1981 returns undefined (implicit void) while lines 1969-1971 and 2029-2031 return cleanup functions. While functionally safe here (since rafId hasn't been set yet), React's rules of hooks recommend effects return either nothing or a cleanup function consistently for readability and to avoid potential future refactoring bugs.

♻️ Suggested fix for consistent return
     if (sourceMode) {
       // Just entered source mode — focus textarea and scroll to anchor block
       const textarea = container?.querySelector(
         "textarea",
       ) as HTMLTextAreaElement | null;
-      if (!textarea) return;
+      if (!textarea) return () => {};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/editor/Editor.tsx` around lines 1976 - 1981, The effect branch
that handles sourceMode currently does an early `if (!textarea) return;` which
returns undefined while other branches return cleanup functions; change that
early return to return a no-op cleanup function so the effect consistently
returns a cleanup function in all branches (e.g., replace the `if (!textarea)
return;` with a check that returns `() => {}`), keeping references to
sourceMode, textarea and the existing rafId/cleanup logic intact so later
refactors won't introduce hook-warning inconsistencies.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/components/editor/Editor.tsx`:
- Around line 1976-1981: The effect branch that handles sourceMode currently
does an early `if (!textarea) return;` which returns undefined while other
branches return cleanup functions; change that early return to return a no-op
cleanup function so the effect consistently returns a cleanup function in all
branches (e.g., replace the `if (!textarea) return;` with a check that returns
`() => {}`), keeping references to sourceMode, textarea and the existing
rafId/cleanup logic intact so later refactors won't introduce hook-warning
inconsistencies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8990a324-286f-4884-bf52-d89104864b28

📥 Commits

Reviewing files that changed from the base of the PR and between 80d23c7 and fb515a8.

📒 Files selected for processing (1)
  • src/components/editor/Editor.tsx

Clamp blockIndexToPos lower bound for empty docs, return consistent
cleanup function from useLayoutEffect early exit, and fall back to
end of markdown (instead of start) when cursor is past the last block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@erictli erictli merged commit 5b5bf08 into main Apr 2, 2026
2 checks passed
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.

Markdown source view should autofocus on load

1 participant