Skip to content

fix(rich-text-editor): DP-188940 fix cursor jump after toggleCodeBlock - #1327

Open
briandial wants to merge 2 commits into
stagingfrom
dp-190125-rte-cursor-fix
Open

fix(rich-text-editor): DP-188940 fix cursor jump after toggleCodeBlock#1327
briandial wants to merge 2 commits into
stagingfrom
dp-190125-rte-cursor-fix

Conversation

@briandial

@briandial briandial commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Obligatory GIF (super important!)

fixing it

🛠️ Type Of Change

  • Fix

📖 Jira Ticket

https://dialpad.atlassian.net/browse/DP-188940

📖 Description

After toggleCodeBlock converts a multi-line code block back to paragraphs, the cursor jumped to the start of the next line instead of staying on the current line.

💡 Context

ProseMirror's replaceWith uses right-biased step mapping (assoc=1) by default. When a multi-line code block is split into paragraphs, each \n becomes a paragraph boundary, and right-bias places the cursor at the start of the next paragraph.

Fix: track prevAnchor and prevInCodeBlock via the selectionUpdate event. On any doc-changing transaction that exits a code block, remap the anchor with left-bias (assoc=-1) and dispatch setTextSelection in setTimeout(0), after ProseMirror's own step mapping has run.

The setTimeout(0) is necessary because setting selection inside a Tiptap chain command is overridden by the EditorView's DOM reconciliation after dispatch. The isDestroyed guard prevents the callback from running if the component unmounts in the interim.

📝 Checklist

  • I have ensured no private Dialpad links or info are in the code or pull request description (Dialtone is a public repo!).
  • I have reviewed my changes.
  • I have added all relevant documentation.
  • I have considered the performance impact of my change.
  • I have added / updated unit tests.
  • I have validated components with a screen reader.
  • I have validated components keyboard navigation.

📷 Screenshots / GIFs

Verified locally via Playwright against Storybook. No visual changes — behaviour fix only.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Fixes cursor jump when toggling multi-line code blocks by tracking the previous selection anchor, remapping it with left-bias when exiting code blocks, and applying setTextSelection via setTimeout(0) to avoid ProseMirror DOM reconciliation overriding the selection.

Overall Judgement: ⚠️ Needs minor changes — Unit tests were added but accessibility validations and remaining checklist items must be completed before merge.

Walkthrough

The rich text editor component adds cursor-jump correction for code block toggles. When exiting a code block via transaction, the component remaps the previous selection anchor using left-biased mapping and asynchronously restores the corrected position to prevent unwanted cursor movement.

Changes

Cursor jump mitigation

Layer / File(s) Summary
Cursor position restoration on code block toggle
packages/dialtone-vue/components/rich_text_editor/rich_text_editor.vue, packages/dialtone-vue/components/rich_text_editor/rich_text_editor.test.js
Tracks prior selection anchor and code-block state in addEditorListeners, remaps the anchor on transactions where the document changed and selection was previously inside a codeBlock, restores the adjusted selection asynchronously via setTextSelection, and adds a test reproducing the trailing-newline ghost-paragraph scenario.

Suggested reviewers

  • francisrupert
  • iropolo
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dp-190125-rte-cursor-fix

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.43.0)
packages/dialtone-vue/components/rich_text_editor/rich_text_editor.test.js

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Sentry


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

@github-actions

Copy link
Copy Markdown
Contributor

Please add either the visual-test-ready or no-visual-test label to this PR depending on whether you want to run visual tests or not.
It is recommended to run visual tests if your PR changes any UI. ‼️

After toggleCodeBlock converts a multi-line code block back to paragraphs,
the cursor jumped to the start of the next paragraph. ProseMirror's
replaceWith uses right-biased step mapping (assoc=1) by default, so each
newline-turned-paragraph-boundary pushes the cursor forward.

Fix: track prevAnchor and prevInCodeBlock via selectionUpdate, then on any
doc-changing transaction that exits a code block, remap the anchor with
left-bias (assoc=-1) and dispatch setTextSelection in setTimeout(0) after
ProseMirror's own step mapping has run.
@briandial
briandial force-pushed the dp-190125-rte-cursor-fix branch from 0896531 to 364b931 Compare June 12, 2026 21:06
@briandial briandial changed the title fix(rich-text-editor): DP-190125 fix cursor jump after toggleCodeBlock fix(rich-text-editor): DP-188940 fix cursor jump after toggleCodeBlock Jun 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0896531c5c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

});
this.editor.on('transaction', ({ editor: txEd, transaction }) => {
if (!transaction.docChanged || !prevInCodeBlock || txEd.isActive('codeBlock')) return;
const corrected = transaction.mapping.map(prevAnchor, -1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the offset when leaving code blocks

When toggleCodeBlock exits a code block it replaces the entire codeBlock node with new paragraph nodes (see the tr.replaceWith(...) command in this file), so transaction.mapping.map(prevAnchor, -1) cannot map an old cursor position inside the deleted node to the corresponding text offset in the inserted paragraphs; with left bias it resolves to the start of the replacement. In the scenario this change is meant to fix—toggling a multi-line code block back to paragraphs with the cursor in the middle or on a later line—the timeout will move the cursor to the start of the converted block rather than keeping it on the current line.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/dialtone-vue/components/rich_text_editor/rich_text_editor.vue`:
- Around line 1330-1346: Add a unit/integration test that verifies the cursor
correction logic around exiting a multi-line code block: create an editor
instance, insert a multi-line codeBlock, set the caret to a non-zero anchor
position, simulate toggling codeBlock to paragraph (triggering selectionUpdate
and transaction handlers), and assert that the final selection.anchor equals the
left-biased mapped position (the corrected value computed from prevAnchor using
transaction.mapping.map with assoc=-1) and that any immediate subsequent editor
commands are not overwritten by the deferred setTextSelection; target the
handlers and symbols prevAnchor, prevInCodeBlock, selectionUpdate, transaction,
setTextSelection, and isDestroyed in your test to reproduce and validate the
deferred correction behavior.
🪄 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: Repository YAML (base), Central YAML (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 148ae436-e71b-434a-8bc9-ad807c771814

📥 Commits

Reviewing files that changed from the base of the PR and between 4d5726b and 364b931.

📒 Files selected for processing (1)
  • packages/dialtone-vue/components/rich_text_editor/rich_text_editor.vue
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • dialpad/ios (manual)
  • dialpad/firespotter (manual)

@github-actions

Copy link
Copy Markdown
Contributor

✔️ Deploy previews ready!
😎 Dialtone documentation preview: https://dialtone.dialpad.com/deploy-previews/pr-1327/
😎 Dialtone-vue preview: https://dialtone.dialpad.com/vue/deploy-previews/pr-1327/

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/dialtone-vue/components/rich_text_editor/rich_text_editor.test.js`:
- Around line 2185-2186: Replace the loose non-empty assertion with a specific
equality check to ensure the cursor is in the expected paragraph: use the
resolved position via ed.state.doc.resolve(ed.state.selection.anchor) (stored as
$anchor) and assert $anchor.parent.textContent === 'line two' instead of
checking not.toBe(''), so the test fails if the cursor lands in any other
non-empty paragraph.
- Around line 2179-2182: Wrap the fake timer usage around the toggleCodeBlock
call in a try/finally to ensure vi.useRealTimers() always runs: call
vi.useFakeTimers() before ed.commands.toggleCodeBlock(), run vi.runAllTimers()
inside the try block after the command, and call vi.useRealTimers() inside
finally; update the block that currently contains vi.useFakeTimers(),
ed.commands.toggleCodeBlock(), vi.runAllTimers(), vi.useRealTimers() accordingly
so timers are restored even if the command or assertions throw.
🪄 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: Repository YAML (base), Central YAML (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: bc79d63a-0aab-46af-a7cb-5556c9364075

📥 Commits

Reviewing files that changed from the base of the PR and between 364b931 and 0c75674.

📒 Files selected for processing (1)
  • packages/dialtone-vue/components/rich_text_editor/rich_text_editor.test.js
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • dialpad/ios (manual)
  • dialpad/firespotter (manual)

Comment on lines +2179 to +2182
vi.useFakeTimers();
ed.commands.toggleCodeBlock();
vi.runAllTimers();
vi.useRealTimers();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Wrap fake timer usage in try/finally to prevent test pollution.

If an error or assertion failure occurs between useFakeTimers() and useRealTimers(), fake timers will leak into subsequent tests, causing flaky failures.

🔒 Proposed fix
-        vi.useFakeTimers();
-        ed.commands.toggleCodeBlock();
-        vi.runAllTimers();
-        vi.useRealTimers();
-        await wrapper.vm.$nextTick();
+        vi.useFakeTimers();
+        try {
+          ed.commands.toggleCodeBlock();
+          vi.runAllTimers();
+          await wrapper.vm.$nextTick();
+        } finally {
+          vi.useRealTimers();
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dialtone-vue/components/rich_text_editor/rich_text_editor.test.js`
around lines 2179 - 2182, Wrap the fake timer usage around the toggleCodeBlock
call in a try/finally to ensure vi.useRealTimers() always runs: call
vi.useFakeTimers() before ed.commands.toggleCodeBlock(), run vi.runAllTimers()
inside the try block after the command, and call vi.useRealTimers() inside
finally; update the block that currently contains vi.useFakeTimers(),
ed.commands.toggleCodeBlock(), vi.runAllTimers(), vi.useRealTimers() accordingly
so timers are restored even if the command or assertions throw.

Source: Coding guidelines

Comment on lines +2185 to +2186
const $anchor = ed.state.doc.resolve(ed.state.selection.anchor);
expect($anchor.parent.textContent).not.toBe('');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a more specific assertion.

Checking that textContent is not empty correctly validates the cursor didn't jump to the ghost paragraph. However, verifying it equals 'line two' would more precisely confirm the cursor is in the expected paragraph and catch regressions where the cursor lands in a different non-empty paragraph.

♻️ Alternative assertion
         const $anchor = ed.state.doc.resolve(ed.state.selection.anchor);
-        expect($anchor.parent.textContent).not.toBe('');
+        expect($anchor.parent.textContent).toBe('line two');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const $anchor = ed.state.doc.resolve(ed.state.selection.anchor);
expect($anchor.parent.textContent).not.toBe('');
const $anchor = ed.state.doc.resolve(ed.state.selection.anchor);
expect($anchor.parent.textContent).toBe('line two');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dialtone-vue/components/rich_text_editor/rich_text_editor.test.js`
around lines 2185 - 2186, Replace the loose non-empty assertion with a specific
equality check to ensure the cursor is in the expected paragraph: use the
resolved position via ed.state.doc.resolve(ed.state.selection.anchor) (stored as
$anchor) and assert $anchor.parent.textContent === 'line two' instead of
checking not.toBe(''), so the test fails if the cursor lands in any other
non-empty paragraph.

@braddialpad Brad Paugh (braddialpad) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

couple of things I've noticed here.

  1. If I put the cursor on an individual point within the codeblock it is fixed, however if all the text in the codeblock is highlighted it's the same problem as before
Image
  1. If you insert a codeblock and then remove it, the cursor now goes to the start of the (prior) codeblock instead of the end. Not a huge problem but I think it feels more natural if it goes to the end (without going to the next line of course)
2026-06-12 15 15 18

@francisrupert

Copy link
Copy Markdown
Contributor

Close? Revisit?

@braddialpad

Copy link
Copy Markdown
Contributor

briandial did you see my comments?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-visual-test Add this tag when the PR does not need visual testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants