Lexical: Improved content insert on drop handling

- Adds specific support for inline content handling.
- Adds attempting to use caret position at drop location for accurate
  placement.
This commit is contained in:
Dan Brown
2026-05-04 20:52:29 +01:00
parent 6917eaf7bd
commit f1452ebe2a
3 changed files with 76 additions and 19 deletions

View File

@@ -1,40 +1,35 @@
import {
$createParagraphNode,
$insertNodes,
$isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
$isDecoratorNode, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
LexicalEditor,
LexicalNode, PASTE_COMMAND
} from "lexical";
import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes";
import {$insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNodePositionFromMouseEvent, $htmlToBlockNodes} 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";
function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null {
const x = event.clientX;
const y = event.clientY;
const dom = document.elementFromPoint(x, y);
if (!dom) {
return null;
}
return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY);
}
function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) {
const positionNode = $getNodeFromMouseEvent(event, editor);
const position = $getNodePositionFromMouseEvent(event, editor);
if (positionNode) {
$selectSingleNode(positionNode);
if (position && $isTextNode(position.node)) {
const selection = position.node.select(position.offset, position.offset);
$setSelection(selection);
} else if (position) {
$selectSingleNode(position.node);
}
$insertNewBlockNodesAtSelection(nodes, true);
$insertNewNodesAtSelection(nodes);
if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) {
positionNode?.remove();
if (position) {
if (!$isDecoratorNode(position.node) && !position.node?.getTextContent()) {
position.node.remove();
}
}
}
@@ -113,6 +108,7 @@ function handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): bo
function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
const editor = context.editor;
return (event: DragEvent): boolean => {
// Template handling
const templateId = event.dataTransfer?.getData('bookstack/template') || '';
if (templateId) {

View File

@@ -1,5 +1,6 @@
import {
$createParagraphNode,
$getNearestNodeFromDOMNode,
$getRoot,
$isDecoratorNode,
$isElementNode, $isRootNode,
@@ -64,6 +65,27 @@ export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNo
return matches;
}
/**
* Get the node based on the given mouse event.
*/
export function $getNodePositionFromMouseEvent(event: MouseEvent, editor: LexicalEditor): {node: LexicalNode, offset: number}|null {
const x = event.clientX;
const y = event.clientY;
const caretPosition = window.document.caretPositionFromPoint(event.x, event.y);
if (!caretPosition) {
const backup = $getNearestBlockNodeForCoords(editor, x, y);
return backup ? {node: backup, offset: 0} : null;
}
const node = $getNearestNodeFromDOMNode(caretPosition.offsetNode);
if (!node) {
const backup = $getNearestBlockNodeForCoords(editor, x, y);
return backup ? {node: backup, offset: 0} : null;
}
return {node, offset: caretPosition.offset};
}
/**
* Get the nearest root/block level node for the given position.
*/

View File

@@ -109,6 +109,45 @@ 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;
}
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)
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);
targetBlock = paragraph;
}
}
}
export function $selectSingleNode(node: LexicalNode) {
const nodeSelection = $createNodeSelection();
nodeSelection.add(node.getKey());