Skip to content

Comments

fix(context-menu): paste via context menu inserts at wrong position (SD-1302)#2110

Merged
harbournick merged 4 commits intomainfrom
tadeu/sd-1302-fix-context-menu-paste
Feb 20, 2026
Merged

fix(context-menu): paste via context menu inserts at wrong position (SD-1302)#2110
harbournick merged 4 commits intomainfrom
tadeu/sd-1302-fix-context-menu-paste

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Feb 19, 2026

The Bug

Pasting via the right-click context menu inserts content at the beginning of the document instead of at the cursor position.

Steps to reproduce:

  1. Type a sentence, press Enter a few times to create empty paragraphs
  2. Select a word → right-click → Copy
  3. Click on an empty paragraph below → right-click → Paste
  4. The word appears at position 0 (top of the document) instead of where the cursor was

Copy, Cut, and Ctrl+V all work correctly — only context-menu Paste is broken.

Root Cause

The paste action in menuItems.js called view.dom.focus() (raw DOM focus) before reading the clipboard. This is subtly different from view.focus() (ProseMirror-aware focus), and the difference is the root cause:

What view.dom.focus() does (broken)

1. Focuses the contenteditable DOM element
2. Triggers ProseMirror's internal `handlers.focus` callback
3. handlers.focus restarts the DOMObserver immediately
4. DOMObserver reads the current browser selection — which is stale
   (collapsed at document start, because the context menu's hidden
   search input had focus, not the editor)
5. DOMObserver overwrites ProseMirror's selection state with position 0
6. The async readClipboardRaw() completes, but selection is now at 0
7. Content gets pasted at position 0

What view.focus() does (correct)

1. Stops the DOMObserver first
2. Focuses the contenteditable DOM element
3. Writes ProseMirror's selection TO the DOM (selectionToDOM)
4. Only then restarts the DOMObserver
→ The observer sees the correct selection, no corruption

The key insight is that when the context menu is open, its hidden search input holds browser focus. The browser's native selection drifts to position 0. Raw dom.focus() lets the DOMObserver read this stale position before ProseMirror can sync its own selection back to the DOM.

Copy and Cut don't have this problem because they already use editor.focus()view.focus() (PM-aware) and are synchronous (no async gap).

The Fix

Two changes, both in the paste action inside menuItems.js:

1. Use view.focus() instead of view.dom.focus()

This prevents the DOMObserver from reading a stale selection during focus.

2. Save/restore selection across the async clipboard read

Even with view.focus(), there's an async gap during await readClipboardRaw() where the DOMObserver could still corrupt the selection (e.g., if the browser fires another focus/blur cycle). As a safety net:

// Save before focus
const savedFrom = view.state.selection.from;
const savedTo = view.state.selection.to;

view.focus();
const { html, text } = await readClipboardRaw();

// Restore after async gap
if (view.state?.doc?.content) {
  const { tr, doc } = view.state;
  const maxPos = doc.content.size;
  const safeFrom = Math.min(savedFrom, maxPos);
  const safeTo = Math.min(savedTo, maxPos);
  const SelectionType = view.state.selection.constructor;
  if (typeof SelectionType.create === 'function') {
    view.dispatch(tr.setSelection(SelectionType.create(doc, safeFrom, safeTo)));
  }
}

The Math.min clamping handles edge cases where the document might shrink during the async gap (e.g., a collaboration update arrives mid-paste). The defensive guards (doc.content check, typeof create === 'function') ensure graceful degradation in environments with incomplete mock/state objects.

Why This Approach

  • Minimal change — only touches the paste action, no architectural changes
  • Matches copy/cut pattern — Copy and Cut already use editor.focus() / view.focus(), this aligns Paste with the same pattern
  • Defense in depthview.focus() fixes the primary issue, save/restore handles async edge cases
  • Safe for collaboration — position clamping prevents RangeError if the document changes during the async clipboard read

Tests

7 new unit tests covering:

  • view.focus() is called instead of view.dom.focus()
  • Selection is saved before focus and restored after clipboard read
  • Position clamping when document shrinks during async gap
  • Graceful behavior when doc.content or SelectionType.create is unavailable
  • Collapsed cursor (from === to) is restored correctly
  • Selection is restored before handleClipboardPaste runs

All 666 test files / 6235 tests pass.

Manual Test Plan

  • Type text, press Enter a few times, select a word → right-click → Copy → click below → right-click → Paste → content appears at cursor
  • Ctrl+V paste still works correctly
  • Context menu Cut still works
  • Context menu Copy still works
  • Paste with HTML content (copy from external app) works at correct position

…SD-1302)

When the context menu is open, its hidden search input holds focus,
causing the ProseMirror editor's contenteditable to lose focus. The
paste action previously called `view.dom.focus()` (raw DOM focus),
which restarts ProseMirror's DOMObserver. The observer reads the stale
browser selection (collapsed at the document start) and overwrites the
PM state, causing content to be pasted at position 0 instead of the
cursor location.

Fix: use `view.focus()` (ProseMirror-aware) which properly syncs the
PM selection to the DOM before restarting the observer. Additionally,
save and restore the selection across the async `readClipboardRaw()`
gap as a safety net against further async drift.
Copilot AI review requested due to automatic review settings February 19, 2026 14:25
@linear
Copy link

linear bot commented Feb 19, 2026

@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a ProseMirror context-menu paste bug where pasted content was inserted at the start of the document by ensuring focus/selection is preserved correctly during the async clipboard read.

Changes:

  • Switches paste action focusing from raw DOM focus (view.dom.focus()) to ProseMirror-aware focus (view.focus()).
  • Saves the current ProseMirror selection before reading from the clipboard and attempts to restore it after the async clipboard read gap.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Verify the paste action saves the cursor position before focusing,
restores it after the async clipboard read, and clamps positions
when the document shrinks during the async gap.
Copy link
Collaborator

@harbournick harbournick left a comment

Choose a reason for hiding this comment

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

LGTM

@harbournick harbournick merged commit 30f03f9 into main Feb 20, 2026
7 checks passed
@harbournick harbournick deleted the tadeu/sd-1302-fix-context-menu-paste branch February 20, 2026 05:40
@superdoc-bot
Copy link
Contributor

superdoc-bot bot commented Feb 20, 2026

🎉 This PR is included in superdoc v1.15.1-next.1

The release is available on GitHub release

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants