mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-10-12 02:01:37 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user