Skip to content

[lexical-clipboard][lexical-rich-text][lexical-plain-text] Bug Fix: Drag-and-drop within the same block#8373

Merged
etrepum merged 26 commits intofacebook:mainfrom
etrepum:claude/fix-text-drag-drop-u3kZg
Apr 23, 2026
Merged

[lexical-clipboard][lexical-rich-text][lexical-plain-text] Bug Fix: Drag-and-drop within the same block#8373
etrepum merged 26 commits intofacebook:mainfrom
etrepum:claude/fix-text-drag-drop-u3kZg

Conversation

@etrepum
Copy link
Copy Markdown
Collaborator

@etrepum etrepum commented Apr 19, 2026

Description

Dragging selected text inside a single block (or within a single TextNode) did not move the text to the drop location — it was typically inserted at the beginning of the block instead. Cross-block drags appeared to work by accident.

Root cause. DROP_COMMAND in registerRichText returned true for range selections without calling preventDefault, so the browser's default drop handling ran. The beforeinput/insertFromDrop path in LexicalEvents only calls selection.applyDOMRange(targetRange) when the selection is collapsed, so with a range selection still active from the drag source the insertion landed on a stale/incorrect selection. registerPlainText disabled drag-and-drop entirely.

Fix. A new shared helper in @lexical/clipboard, exposed as $handleRichTextDrop and $handlePlainTextDrop, implements text drag-and-drop:

  • Bails for drags with no application/x-lexical-drag marker (i.e. external drags), letting the browser's native drag-and-drop flow and Lexical's existing beforeinput insertFromDrop handler take over.
  • Otherwise, resolves the drop point from event.clientX/Y via caretFromPoint to a Lexical PointCaret, then splits at that caret with $splitAtPointCaretNext to obtain a stable NodeCaret (an element-point boundary between two siblings) that survives any text-content mutation in its siblings — the same code path handles same-text-node, cross-text-node, cross-block, and element-point drops without bespoke offset adjustments.
  • For drops from a non-collapsed RangeSelection in the same editor, removes the dragged range and inserts the DataTransfer payload at the stable drop caret. Drops that land strictly inside the source range are a no-op (single $comparePointCaretNext check against the source's anchor/focus).
  • Calls event.preventDefault() on the handled paths so the browser doesn't also run its own drop behavior.

registerRichText wires $handleRichTextDrop; registerPlainText wires $handlePlainTextDrop and no longer cancels DRAGSTART_COMMAND.

Preserving non-text nodes. DRAGSTART_COMMAND in rich-text now populates the DataTransfer via setLexicalClipboardDataTransfer + $getClipboardDataFromSelection, so DecoratorNodes (images, etc.) and formatting survive the drop instead of being downgraded to text/html.

Cross-editor drags. $writeDragSourceToDataTransfer writes a {editorKey} marker into application/x-lexical-drag on DRAGSTART (called from both rich-text and plain-text). On DROP, when the marker's editor key differs from the destination editor's, the destination's handler:

  1. Inserts the payload at the drop caret.
  2. Looks up the source editor's root element in the same document via [data-lexical-editor="true"] + __lexicalEditor.getKey().
  3. Dispatches a synthetic InputEvent('beforeinput', {inputType: 'deleteByDrag'}) at that root. This is what the browser would have fired natively if we hadn't preventDefaulted the drop — the browser's own deleteByDrag isn't reliable when the source is a nested contenteditable of the destination (e.g. an image caption inside the main editor). The source editor's own beforeinput handler runs the deletion through its own REMOVE_TEXT_COMMAND against its own current selection (Lexical preserves the source selection across the drag).

The synthetic-event approach means cross-editor drag works correctly even when two different versions of Lexical are loaded on the same page — there's no shared module-level state and no need for the destination's $createRangeSelection / $getSelection to interoperate with the source's. The marker carries only the editor key; the source's selection is read live from the source editor at deletion time, so there's no stale-node-key risk.

Focus. $handleBeforeInput in LexicalEvents now adds SKIP_SELECTION_FOCUS_TAG to the update when it dispatches REMOVE_TEXT_COMMAND for deleteByDrag, so the source editor's reconciliation doesn't pull DOM focus or the document's selection back to itself after the drag-out. The destination editor keeps focus without any explicit re-focus call.

Playground. FloatingTextFormatToolbarPlugin hides its popup while a drag is in progress (tracked via dragstart / dragend / drop at the document level) so the toolbar doesn't sit on top of the drag image or the drop target and doesn't re-render from selectionchange during the drag.

Closes #353

Test plan

Unit tests in packages/lexical/src/__tests__/unit/HandleTextDrop.test.ts cover same-block forward/backward moves, drops inside the source range (single-node and multi-node), cross-TextNode inserts, the external-drag bail, backward selections, DecoratorNode preservation, and the cross-editor dispatch contract.

E2E spec in packages/lexical-playground/__tests__/e2e/TextDragDrop.spec.mjs exercises the same-block cases for both rich-text and plain-text by dispatching synthesized DragEvents (Playwright's mouse API does not fire native drag events).

Manual playground repros confirm: same-block drag moves the text correctly, an image (DecoratorNode) survives a drag-and-drop, and a caption-to-main-editor drag cuts from the caption and pastes into main with focus ending in main.

…ove text on drag-and-drop within the same block

Text drag-and-drop inside a single block (or within a single TextNode)
previously fell through to the browser's default drop handling, which
caused the dragged text to be inserted in the wrong place (typically at
the beginning of the block) because Lexical's selection still pointed at
the dragged range when beforeinput events fired.

DROP_COMMAND now resolves the drop location from the pointer coordinates,
removes the dragged range (adjusting the drop offset when the drop lands
in the same text node as the source), and inserts the DataTransfer
payload at the resolved location. The shared implementation lives in
@lexical/clipboard as $handleTextDrop and is used by both registerRichText
and registerPlainText (plain-text drag-and-drop is no longer disabled).

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Apr 22, 2026 5:00pm
lexical-playground Ready Ready Preview, Comment Apr 22, 2026 5:00pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Apr 19, 2026
…t drag-and-drop

Adds a unit test suite (HandleTextDrop.test.ts) that stubs
shared/caretFromPoint to exercise the core scenarios of $handleTextDrop:
forward move, backward move, no-op drop inside the source, cross-TextNode
move in the same block, external-drag insertion, null drop location, and
a backward RangeSelection source.

Adds a playground e2e spec (TextDragDrop.spec.mjs) that dispatches
synthetic HTML5 DragEvents with a DataTransfer payload and asserts the
resulting HTML for both rich-text and plain-text editors. Playwright
does not fire native drag events via its mouse API, so the spec uses
page.evaluate to construct and dispatch the events directly.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…es when drag-dropping a RangeSelection

The previous drop handler relied on the browser-populated text/html and
text/plain entries in the DataTransfer, so any DecoratorNode (e.g.
ImageNode) in the drag source would disappear on drop because the
browser's auto-generated HTML did not include it. The handler also only
detected drops inside the source range for the single-TextNode case.

DRAGSTART_COMMAND in registerRichText now populates the DataTransfer
with Lexical's own clipboard serialization
(application/x-lexical-editor via setLexicalClipboardDataTransfer +
$getClipboardDataFromSelection). This preserves all node types in the
source, including decorators, formatting, and block structure.

$handleTextDrop has been generalized:

- $isDropInsideSourceRange now consults source.getNodes() and walks
  ancestors, so drops into any descendant of a source node are detected
  regardless of how many text/element nodes the range spans.
- $adjustDropLocationForRemoval also handles the same-parent element-
  point case (children indexed past the removed range shift left).

Two new unit tests cover these behaviors: a DecoratorNode inside the
source survives a drop into a different paragraph, and a drop strictly
inside a multi-TextNode range is a no-op.

Note: the current implementation still inserts via
$insertDataTransferForRichText, which creates new nodes with new keys
from the serialized payload. A true in-place move that preserves node
identity (and therefore per-node runtime state) can be layered on top
of this change later.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
The original DecoratorNode test only counted decorators in the tree,
which would pass as long as the decorator wasn't duplicated or lost —
it wouldn't catch a regression where the decorator remains stuck in the
source paragraph, or where surrounding text is dropped to the wrong
location.

Assert more specifically:
- Exactly two top-level paragraphs after the drop.
- The decorator lives under the target paragraph, not the source.
- The source paragraph is empty.
- The target paragraph's textContent is "target" + "before" +
  <decorator textContent> + "after", which exercises both node
  preservation and drop ordering.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
@levensta

This comment was marked as resolved.

@etrepum

This comment was marked as resolved.

…ut-and-paste semantics for cross-editor text drag-and-drop

Drag-and-drop between two Lexical editors on the same page (e.g. from a
nested image-caption editor into the main content editor, or vice versa)
previously lost the source content: the destination editor inserted the
DataTransfer payload but the source range was never removed, because
DROP_COMMAND runs in the destination editor and cannot see the source
editor's selection.

Add a small cross-editor coordination API in @lexical/clipboard:

- $setDragSource(editor): captures a clone of the current non-collapsed
  RangeSelection as the active drag source. Called from DRAGSTART in
  both registerRichText and registerPlainText.
- clearDragSource(): clears any recorded source. Called from DRAGEND.

$handleTextDrop now checks the module-level drag source. When the drop
editor differs from the source editor, it inserts the DataTransfer at
the drop location (same as before) and then schedules a separate update
on the source editor to remove the recorded selection — producing
cut-and-paste semantics across editors. When source and destination are
the same editor, behavior is unchanged.

Tests cover:
- Dropping a word from one editor into another removes it from the
  source and appends it to the destination.
- A cancelled drag (cleared source without a drop) leaves both editors
  untouched.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
… Caption

Clicking the "Add Caption" button leaves browser focus on the button,
and setShowCaption(true) only sets a selection in the caption editor's
state — it never transfers DOM focus to the newly mounted
contenteditable. On Chromium, Lexical's selection reconciliation does
not call rootElement.focus() in the path that applies a fresh selection
(only the early-return path focuses), so the caret becomes visible in
the caption while keystrokes continue to be routed to the button.

The bug is easiest to reproduce after a full open/blur/open cycle (as
noted in the original report), but it exists on the very first click
too — the user simply doesn't notice until they come back a second time
and actually try to type.

Defer a requestAnimationFrame tick so React can mount the caption
editor's root element, then explicitly focus it and sync the Lexical
selection via editor.focus(). This is independent of the drag-and-drop
work in the rest of this branch.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…ted editors on component unmount

LexicalExtensionEditorComposer unconditionally called
initialEditor.dispose() on unmount, which tears down the editor's
extension registrations (commands, transforms, listeners). For
stand-alone editors this is correct, but nested editors whose lifetime
is owned by a parent node (e.g. the image caption editor stored on
ImageNode.__caption) are reused across mount cycles. After the first
unmount, the caption editor had no CONTROLLED_TEXT_INSERTION_COMMAND
handler, so beforeinput events reached Lexical but produced no text
insertion — typing appeared to do nothing.

Reproduction:

  1. insert an image
  2. click the image
  3. click "Add caption"
  4. click in the parent editor (closes the empty caption via
     DisableCaptionOnBlur + setShowCaption(false))
  5. click the image again
  6. click "Add caption"
  -> the contenteditable is focused but typing does nothing

Add an optional `disposeOnUnmount` prop to
LexicalExtensionEditorComposer (default `true` to preserve existing
behaviour). ImageComponent passes `disposeOnUnmount={false}` so the
caption editor survives the caption UI's mount/unmount cycles.

Verified with a headless-browser reproduction that the caption accepts
typing after the open/close/open cycle once this fix is applied.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…CaretInsideSelection

Replace the manual anchor/focus-caret + isBackward normalization with
$getCaretRangeInDirection($caretRangeFromSelection(selection), 'next'),
which hands back a CaretRange whose anchor/focus are always the logical
start/end in tree order.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…ested contenteditables

Reverting the assumption that the browser's native drag-drop flow handles
cross-editor drops correctly. For a nested contenteditable setup (e.g. an
image caption inside the main editor), dragging out of the nested editor
does not reliably fire beforeinput insertFromDrop on the outer editor —
the drop ends up routed to the wrong destination.

Restore the explicit cross-editor path: insert at the drop caret in the
destination editor ourselves, and dispatch a synthetic beforeinput
deleteByDrag at the source editor's root so the source editor's own
beforeinput handler (and SKIP_SELECTION_FOCUS_TAG path) runs the
deletion. Drops from outside any Lexical editor (no marker) still fall
through to the browser's native flow.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…g drag

The floating text format toolbar watches selectionchange and re-renders
itself during a drag, sitting on top of the drag image and the drop
target. Track document-level dragstart/dragend/drop and suppress the
popup while a drag is in progress.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…oords haven't moved

Browsers fire dragover continuously during a drag (roughly every 350ms)
regardless of cursor movement. The rich-text DRAGOVER_COMMAND handler
did a DOM caretFromPoint + tree walk on every event to decide whether
to preventDefault over a DecoratorNode. Cache the last (clientX,
clientY) and the resulting decision, and replay it when the coordinates
haven't changed. Reset the cache on DRAGEND so a stale decision from
the previous drag's final position doesn't carry over.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
…t when coords haven't moved"

The repeated dragover dispatches observed in dev-tools logging aren't a
real runtime concern, so the coordinate-based dedup cache + DRAGEND
reset weren't worth carrying. Restore the straightforward handler.

This reverts commit d718a95.

https://claude.ai/code/session_017sB6xjtN2M9PXWBuVsvpM2
@etrepum etrepum added this pull request to the merge queue Apr 23, 2026
Merged via the queue into facebook:main with commit 2c37dc2 Apr 23, 2026
41 checks passed
vishisht31 pushed a commit that referenced this pull request Apr 23, 2026
…rag-and-drop within the same block (#8373)

Co-authored-by: Claude <noreply@anthropic.com>
levensta added a commit to levensta/lexical that referenced this pull request Apr 24, 2026
commit 5d1bc33
Author: Sathvik Veerapaneni <98241593+Sathvik-Chowdary-Veerapaneni@users.noreply.github.com>
Date:   Thu Apr 23 13:12:21 2026 -0400

    [lexical-list] Bug Fix: Merge nested list into parent <li> during HTML export (facebook#8313)

    Co-authored-by: Bob Ippolito <bob@redivi.com>

commit 2c37dc2
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Apr 23 07:14:14 2026 -0700

    [lexical-clipboard][lexical-rich-text][lexical-plain-text] Bug Fix: Drag-and-drop within the same block (facebook#8373)

    Co-authored-by: Claude <noreply@anthropic.com>

commit ca2aa31
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Apr 23 07:12:43 2026 -0700

    [lexical][lexical-utils][lexical-list] Bug Fix: Clean up and test $insertNodeToNearestRootAtCaret edge cases (facebook#8384)

commit 207648e
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Apr 23 07:11:52 2026 -0700

    [lexical-html][lexical-playground] Feature: Implement a well-defined ordering for DOMRenderExtension overrides and add $decorateDOM (facebook#8368)

commit 1ca42f1
Author: Agyei Holy <agyeiholy978@gmail.com>
Date:   Wed Apr 22 15:39:37 2026 -0500

    [lexical][lexical-code-core][lexical-list][lexical-table][lexical-yjs] Refactor: make runtime style updates CSP-safe (facebook#8372)

commit ca0ce82
Author: Bob Ippolito <bob@redivi.com>
Date:   Wed Apr 22 12:57:28 2026 -0700

    [lexical-list] Bug Fix: Ensure that ListItemNode always has a ListItem parent (facebook#8382)

commit f4c44e1
Author: Sherry <potatowagon@meta.com>
Date:   Thu Apr 23 00:28:54 2026 +0530

    [lexical-markdown] Bug Fix: Code spans take precedence over inline formatting in shortcuts (facebook#8381)

commit 4a43cb0
Author: Sergey Gorbachev <grbchv.s@gmail.com>
Date:   Wed Apr 22 18:31:21 2026 +0300

    [lexical-playground] Feature: HTML conversion button (facebook#8379)
levensta added a commit to levensta/lexical that referenced this pull request Apr 24, 2026
commit 5d1bc33
Author: Sathvik Veerapaneni <98241593+Sathvik-Chowdary-Veerapaneni@users.noreply.github.com>
Date:   Thu Apr 23 13:12:21 2026 -0400

    [lexical-list] Bug Fix: Merge nested list into parent <li> during HTML export (facebook#8313)

    Co-authored-by: Bob Ippolito <bob@redivi.com>

commit 2c37dc2
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Apr 23 07:14:14 2026 -0700

    [lexical-clipboard][lexical-rich-text][lexical-plain-text] Bug Fix: Drag-and-drop within the same block (facebook#8373)

    Co-authored-by: Claude <noreply@anthropic.com>

commit ca2aa31
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Apr 23 07:12:43 2026 -0700

    [lexical][lexical-utils][lexical-list] Bug Fix: Clean up and test $insertNodeToNearestRootAtCaret edge cases (facebook#8384)

commit 207648e
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Apr 23 07:11:52 2026 -0700

    [lexical-html][lexical-playground] Feature: Implement a well-defined ordering for DOMRenderExtension overrides and add $decorateDOM (facebook#8368)

commit 1ca42f1
Author: Agyei Holy <agyeiholy978@gmail.com>
Date:   Wed Apr 22 15:39:37 2026 -0500

    [lexical][lexical-code-core][lexical-list][lexical-table][lexical-yjs] Refactor: make runtime style updates CSP-safe (facebook#8372)

commit ca0ce82
Author: Bob Ippolito <bob@redivi.com>
Date:   Wed Apr 22 12:57:28 2026 -0700

    [lexical-list] Bug Fix: Ensure that ListItemNode always has a ListItem parent (facebook#8382)

commit f4c44e1
Author: Sherry <potatowagon@meta.com>
Date:   Thu Apr 23 00:28:54 2026 +0530

    [lexical-markdown] Bug Fix: Code spans take precedence over inline formatting in shortcuts (facebook#8381)

commit 4a43cb0
Author: Sergey Gorbachev <grbchv.s@gmail.com>
Date:   Wed Apr 22 18:31:21 2026 +0300

    [lexical-playground] Feature: HTML conversion button (facebook#8379)
@etrepum etrepum mentioned this pull request Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request: Support dragging & dropping text

4 participants