Lexical: Added clipboard/drag handling for single nodes

Fixes behaviour when using cut/copy/drag for items like images or code
blocks.

Also updates history handling to not ignore additions where original
content remains unchanged.
This commit is contained in:
Dan Brown
2026-09-07 12:24:28 +01:00
parent f8c2ada021
commit e08f11d6ff
7 changed files with 109 additions and 8 deletions
@@ -6,7 +6,7 @@
*
*/
import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
import {$generateHtmlFromNode, $generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
import {objectKlassEquals} from '@lexical/utils';
import {
@@ -31,6 +31,8 @@ import {
} from 'lexical';
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
import invariant from 'lexical/shared/invariant';
import {$getSingleSelectableNode} from "../../utils/nodes";
import {$createSingleNodeSelection, $selectSingleNode} from "../../utils/selection";
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
@@ -58,6 +60,11 @@ export function $getHtmlContent(
if (selection == null) {
invariant(false, 'Expected valid LexicalSelection');
}
// Handle single node selections
const singleSelectableNode = $getSingleSelectableNode(selection.getNodes());
if (singleSelectableNode) {
return $generateHtmlFromNode(editor, singleSelectableNode);
}
// If we haven't selected anything
if (
@@ -88,6 +95,12 @@ export function $getLexicalContent(
invariant(false, 'Expected valid LexicalSelection');
}
// Handle single node selections
const singleSelectableNode = $getSingleSelectableNode(selection.getNodes());
if (singleSelectableNode) {
return JSON.stringify($generateJSONFromNode(editor, singleSelectableNode));
}
// If we haven't selected anything
if (
($isRangeSelection(selection) && selection.isCollapsed()) ||
@@ -354,6 +367,26 @@ export function $generateJSONFromSelectedNodes<
};
}
/**
* Gets the Lexical JSON of the given node.
*/
export function $generateJSONFromNode<
SerializedNode extends BaseSerializedNode,
>(
editor: LexicalEditor,
node: LexicalNode,
): {
namespace: string;
node: SerializedNode;
} {
const serialized: Array<SerializedNode> = [];
$appendNodesToJSON(editor, null, node, serialized);
return {
namespace: editor._config.namespace,
node: serialized[0],
};
}
/**
* This method takes an array of objects conforming to the BaseSeralizedNode interface and returns
* an Array containing instances of the corresponding LexicalNode classes registered on the editor.
@@ -294,9 +294,12 @@ function createMergeActionGetter(
return HISTORY_MERGE;
}
// A single node might have been marked as dirty, but not have changed
// A single node might have been marked as dirty but not have changed
// due to some node transform reverting the change.
if (dirtyLeaves.size === 1) {
const hasIntentionalElementChanges = Array.from(
dirtyElements.values(),
).some((value) => value);
if (!hasIntentionalElementChanges && dirtyLeaves.size === 1) {
const dirtyLeafKey = Array.from(dirtyLeaves)[0];
if (
isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState)
@@ -99,6 +99,15 @@ export function $generateHtmlFromNodes(
return nodeCode.join('\n');
}
/**
* Generates HTML from a single given node (and its children), regardless of selection.
*/
export function $generateHtmlFromNode(editor: LexicalEditor, node: LexicalNode): string {
const container = document.createElement('div');
$appendNodesToHTML(editor, node, container);
return container.innerHTML;
}
function $appendNodesToHTML(
editor: LexicalEditor,
currentNode: LexicalNode,
@@ -66,6 +66,7 @@ import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexica
import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils';
import caretFromPoint from 'lexical/shared/caretFromPoint';
import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment';
import {$getSingleSelectableNode} from "../../utils/nodes";
export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
'DRAG_DROP_PASTE_FILE',
@@ -107,7 +108,12 @@ async function onCutForRichText(
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.removeText();
const singleSelectable = $getSingleSelectableNode(selection.getNodes())
if (singleSelectable) {
singleSelectable.remove();
} else {
selection.removeText();
}
} else if ($isNodeSelection(selection)) {
selection.getNodes().forEach((node) => node.remove());
}
@@ -7,13 +7,17 @@ import {
LexicalNode, PASTE_COMMAND
} from "lexical";
import {$getBlockElementNodesInSelection, $insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNodePositionFromMouseEvent, $htmlToBlockNodes, $htmlToNodes} from "../utils/nodes";
import {
$getNodePositionFromMouseEvent,
$getSingleSelectableNode, $htmlToBlockNodes, $htmlToNodes,
} from "../utils/nodes";
import {Clipboard} from "../../services/clipboard";
import {$createImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$createLinkNode} from "@lexical/link";
import {EditorImageData, uploadImageFile} from "../utils/images";
import {EditorUiContext} from "../ui/framework/core";
import {$getHtmlContent} from "@lexical/clipboard";
import {$generateHtmlFromNode} from "@lexical/html";
const internalActiveDragTracker: WeakMap<LexicalEditor, DragEvent> = new WeakMap();
@@ -53,6 +57,14 @@ function insertHtmlToEditor(editor: LexicalEditor, html: string, isFromInternal:
const selected = $getSelection();
if ($isRangeSelection(selected)) {
selected.removeText();
// Handle single selected node scenarios
const singleSelectedNode = $getSingleSelectableNode(selected.getNodes());
if (singleSelectedNode) {
singleSelectedNode.remove();
}
// Clear out empty blocks
const selectionBlocks = $getBlockElementNodesInSelection(selected);
for (const block of selectionBlocks) {
if (block.isEmpty()) {
@@ -194,6 +206,14 @@ function createDragStartListener(context: EditorUiContext): (event: DragEvent) =
context.editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const singleSelectableNode = $getSingleSelectableNode(selection.getNodes());
if (singleSelectableNode) {
const html = $generateHtmlFromNode(context.editor, singleSelectableNode);
event.dataTransfer?.setData('text/html', html);
return;
}
selection.extract();
const html = $getHtmlContent(context.editor, selection);
event.dataTransfer?.setData('text/html', html);
+20
View File
@@ -17,6 +17,7 @@ import {$findMatchingParent} from "@lexical/utils";
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {$isDiagramNode} from "./diagrams";
import {$isLinkedImageNode} from "./images";
function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
return nodes.map(node => {
@@ -180,6 +181,25 @@ export function $isSingleSelectableNode(nodes: LexicalNode[]): boolean {
return false;
}
/**
* Get the single selectable node if the given range represents a single selectable node.
* Typically called with a selection's node ranges.
* Normalises the result, like for linked images, for example.
*/
export function $getSingleSelectableNode(nodes: LexicalNode[]): LexicalNode|null {
if (!$isSingleSelectableNode(nodes)) {
return null;
}
const node = nodes[0];
if ($isLinkedImageNode(node)) {
return node.getParent();
}
return node;
}
export function nodeHasAlignment(node: object): node is NodeHasAlignment {
return '__alignment' in node;
}
+13 -3
View File
@@ -8,7 +8,7 @@ import {
$setSelection,
BaseSelection, DecoratorNode,
ElementNode, LexicalEditor,
LexicalNode, RangeSelection,
LexicalNode, NodeSelection, RangeSelection,
TextFormatType, TextNode
} from "lexical";
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
@@ -164,10 +164,20 @@ export function $insertNewNodesAtSelection(nodes: LexicalNode[]) {
}
}
export function $selectSingleNode(node: LexicalNode) {
/**
* Create a selection for a single node.
*/
export function $createSingleNodeSelection(node: LexicalNode): NodeSelection {
const nodeSelection = $createNodeSelection();
nodeSelection.add(node.getKey());
$setSelection(nodeSelection);
return nodeSelection;
}
/**
* Create and apply a selection for a single node.
*/
export function $selectSingleNode(node: LexicalNode): void {
$setSelection($createSingleNodeSelection(node));
}
function getFirstTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {