mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-07-15 21:31:36 +03:00
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:
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user