Lexical: Fixed in-editor content drag and drop

Now more reliable and aligned to expectations, instead of loosing
information and/or deleting elements, or inserting above/below blocks.
This commit is contained in:
Dan Brown
2026-05-04 22:32:19 +01:00
parent f1452ebe2a
commit 0eed869735
2 changed files with 66 additions and 31 deletions

View File

@@ -1,18 +1,21 @@
import {
$createParagraphNode,
$createParagraphNode, $getSelection,
$insertNodes,
$isDecoratorNode, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
$isDecoratorNode,
$isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND,
LexicalEditor,
LexicalNode, PASTE_COMMAND
} from "lexical";
import {$insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNodePositionFromMouseEvent, $htmlToBlockNodes} from "../utils/nodes";
import {$getBlockElementNodesInSelection, $insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNodePositionFromMouseEvent, $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";
const internalActiveDragTracker: WeakMap<LexicalEditor, DragEvent> = new WeakMap();
function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) {
const position = $getNodePositionFromMouseEvent(event, editor);
@@ -44,6 +47,26 @@ async function insertTemplateToEditor(editor: LexicalEditor, templateId: string,
});
}
function insertHtmlToEditor(editor: LexicalEditor, html: string, isFromInternal: boolean, event: DragEvent) {
editor.update(() => {
if (isFromInternal) {
const selected = $getSelection();
if ($isRangeSelection(selected)) {
selected.removeText();
const selectionBlocks = $getBlockElementNodesInSelection(selected);
for (const block of selectionBlocks) {
if (block.isEmpty()) {
block.remove();
}
}
}
}
const newNodes = $htmlToNodes(editor, html);
$insertNodesAtEvent(newNodes, event, editor);
});
}
function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean {
const clipboard = new Clipboard(data);
let handled = false;
@@ -109,6 +132,9 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo
const editor = context.editor;
return (event: DragEvent): boolean => {
const hadInternalActiveDrag = internalActiveDragTracker.has(editor);
internalActiveDragTracker.delete(editor);
// Template handling
const templateId = event.dataTransfer?.getData('bookstack/template') || '';
if (templateId) {
@@ -121,10 +147,7 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo
// HTML contents drop
const html = event.dataTransfer?.getData('text/html') || '';
if (html) {
editor.update(() => {
const newNodes = $htmlToBlockNodes(editor, html);
$insertNodesAtEvent(newNodes, event, editor);
});
insertHtmlToEditor(editor, html, hadInternalActiveDrag, event);
event.preventDefault();
event.stopPropagation();
return true;
@@ -161,17 +184,39 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent)
};
}
function createDragStartListener(context: EditorUiContext): (event: DragEvent) => boolean {
return (event: DragEvent) => {
// Track when drag events are started internally from the editor
internalActiveDragTracker.set(context.editor, event);
// If an internal range selection, serialize the range contents
// fully as output HTML, instead of editor HTML
context.editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.extract();
const html = $getHtmlContent(context.editor, selection);
event.dataTransfer?.setData('text/html', html);
}
});
return false;
};
}
export function registerDropPasteHandling(context: EditorUiContext): () => void {
const dropListener = createDropListener(context);
const pasteListener = createPasteListener(context);
const dragstartListener = createDragStartListener(context);
const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH);
const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH);
const unregisterDragStart = context.editor.registerCommand(DRAGSTART_COMMAND, dragstartListener, COMMAND_PRIORITY_HIGH);
context.scrollDOM.addEventListener('drop', dropListener);
return () => {
unregisterDrop();
unregisterPaste();
unregisterDragStart();
context.scrollDOM.removeEventListener('drop', dropListener);
};
}

View File

@@ -111,38 +111,28 @@ export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfte
export function $insertNewNodesAtSelection(nodes: LexicalNode[]) {
const selection = $getSelection();
const selectionPoints = selection?.getStartEndPoints();
let target: LexicalNode|null = null;
let targetBlock: LexicalNode|null = null;
if (selectionPoints) {
const selectionEnd = selectionPoints[1];
target = selectionEnd.getNode();
targetBlock = target ? $getNearestNodeBlockParent(target) : null;
if (selection) {
selection.insertNodes(nodes);
return;
}
// Do something relatively sensible if we don't have a selection within view
const root = $getRoot();
let targetBlock = root.getLastChild();
for (const node of nodes) {
const isBlock = $isBlockElementNode(node);
if (isBlock && !targetBlock) {
// Append to the root if its a block and we can't determine position
$getRoot().append(node);
} else if (isBlock && targetBlock) {
// Insert after the target block if we have a block
targetBlock.insertAfter(node);
} else if (!isBlock && selection) {
// Insert at selection if likely inline
selection.insertNodes(nodes);
} else if (!isBlock && $isElementNode(targetBlock)) {
// Append inside the target block if inline but we don't have
// a selection (typically used by the case below)
root.append(node);
targetBlock = node;
} else if (isBlock) {
targetBlock?.insertAfter(node);
targetBlock = node;
} else if ($isElementNode(targetBlock)) {
targetBlock.append(node);
} else {
// Otherwise (where inline) create a new root level paragraph
// and insert content into that. Update the target block
// for re-use by other inline elements.
const paragraph = $createParagraphNode();
paragraph.append(node);
$getRoot().append(paragraph);
root.append(paragraph);
targetBlock = paragraph;
}
}