From 0eed869735eec0f26bb911cd742ad74ccb63e5e4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 4 May 2026 22:32:19 +0100 Subject: [PATCH] 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. --- .../wysiwyg/services/drop-paste-handling.ts | 61 ++++++++++++++++--- resources/js/wysiwyg/utils/selection.ts | 36 ++++------- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index b63c57ada..e11d6bd5e 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -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 = 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); }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index f5d3699fc..28050571e 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -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; } }