[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 Apr 23, 2026
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
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
zurfyx
approved these changes
Apr 23, 2026
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)
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_COMMANDinregisterRichTextreturnedtruefor range selections without callingpreventDefault, so the browser's default drop handling ran. Thebeforeinput/insertFromDroppath inLexicalEventsonly callsselection.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.registerPlainTextdisabled drag-and-drop entirely.Fix. A new shared helper in
@lexical/clipboard, exposed as$handleRichTextDropand$handlePlainTextDrop, implements text drag-and-drop:application/x-lexical-dragmarker (i.e. external drags), letting the browser's native drag-and-drop flow and Lexical's existingbeforeinput insertFromDrophandler take over.event.clientX/YviacaretFromPointto a LexicalPointCaret, then splits at that caret with$splitAtPointCaretNextto obtain a stableNodeCaret(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.RangeSelectionin the same editor, removes the dragged range and inserts theDataTransferpayload at the stable drop caret. Drops that land strictly inside the source range are a no-op (single$comparePointCaretNextcheck against the source's anchor/focus).event.preventDefault()on the handled paths so the browser doesn't also run its own drop behavior.registerRichTextwires$handleRichTextDrop;registerPlainTextwires$handlePlainTextDropand no longer cancelsDRAGSTART_COMMAND.Preserving non-text nodes.
DRAGSTART_COMMANDin rich-text now populates theDataTransferviasetLexicalClipboardDataTransfer+$getClipboardDataFromSelection, soDecoratorNodes (images, etc.) and formatting survive the drop instead of being downgraded totext/html.Cross-editor drags.
$writeDragSourceToDataTransferwrites a{editorKey}marker intoapplication/x-lexical-dragonDRAGSTART(called from both rich-text and plain-text). OnDROP, when the marker's editor key differs from the destination editor's, the destination's handler:[data-lexical-editor="true"]+__lexicalEditor.getKey().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 owndeleteByDragisn'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 ownbeforeinputhandler runs the deletion through its ownREMOVE_TEXT_COMMANDagainst 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/$getSelectionto 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.
$handleBeforeInputinLexicalEventsnow addsSKIP_SELECTION_FOCUS_TAGto the update when it dispatchesREMOVE_TEXT_COMMANDfordeleteByDrag, 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.
FloatingTextFormatToolbarPluginhides its popup while a drag is in progress (tracked viadragstart/dragend/dropat the document level) so the toolbar doesn't sit on top of the drag image or the drop target and doesn't re-render fromselectionchangeduring the drag.Closes #353
Test plan
Unit tests in
packages/lexical/src/__tests__/unit/HandleTextDrop.test.tscover same-block forward/backward moves, drops inside the source range (single-node and multi-node), cross-TextNodeinserts, the external-drag bail, backward selections,DecoratorNodepreservation, and the cross-editor dispatch contract.E2E spec in
packages/lexical-playground/__tests__/e2e/TextDragDrop.spec.mjsexercises the same-block cases for both rich-text and plain-text by dispatching synthesizedDragEvents (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.