/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import {$createListNode, $isListNode} from '@lexical/list'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {eventFiles} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; import { $getNearestNodeFromDOMNode, $getNodeByKey, $getRoot, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DRAGOVER_COMMAND, DROP_COMMAND, LexicalEditor, LexicalNode, } from 'lexical'; import { DragEvent as ReactDragEvent, TouchEvent, useCallback, useEffect, useRef, useState, } from 'react'; import {createPortal} from 'react-dom'; import {BlockIcon} from '@standardnotes/icons'; import {isHTMLElement} from '../../Utils/guard'; import {Point} from '../../Utils/point'; import {ContainsPointReturn, Rect} from '../../Utils/rect'; const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2; const TARGET_LINE_HALF_HEIGHT = 2; const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'; const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'; const TEXT_BOX_HORIZONTAL_PADDING = 24; const Downward = 1; const Upward = -1; const Indeterminate = 0; let prevIndex = Infinity; function getCurrentIndex(keysLength: number): number { if (keysLength === 0) { return Infinity; } if (prevIndex >= 0 && prevIndex < keysLength) { return prevIndex; } return Math.floor(keysLength / 2); } function getTopLevelNodeKeys(editor: LexicalEditor): string[] { return editor.getEditorState().read(() => $getRoot().getChildrenKeys()); } function elementContainingEventLocation( anchorElem: HTMLElement, element: HTMLElement, eventLocation: Point, ): {contains: ContainsPointReturn; element: HTMLElement} { const anchorElementRect = anchorElem.getBoundingClientRect(); const elementDomRect = Rect.fromDOM(element); const {marginTop, marginBottom} = window.getComputedStyle(element); const rect = elementDomRect.generateNewRect({ bottom: elementDomRect.bottom + parseFloat(marginBottom), left: anchorElementRect.left, right: anchorElementRect.right, top: elementDomRect.top - parseFloat(marginTop), }); const children = Array.from(element.children); const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes( element.tagName, ); if (shouldRecurseIntoChildren) { for (const child of children) { const isLeaf = child.children.length === 0; if (isLeaf) { continue; } const childResult = elementContainingEventLocation( anchorElem, child as HTMLElement, eventLocation, ); if (childResult.contains.result) { return childResult; } } } return {contains: rect.contains(eventLocation), element: element}; } function getBlockElement( anchorElem: HTMLElement, editor: LexicalEditor, eventLocation: Point, ): HTMLElement | null { const topLevelNodeKeys = getTopLevelNodeKeys(editor); let blockElem: HTMLElement | null = null; editor.getEditorState().read(() => { let index = getCurrentIndex(topLevelNodeKeys.length); let direction = Indeterminate; while (index >= 0 && index < topLevelNodeKeys.length) { const key = topLevelNodeKeys[index]; const elem = editor.getElementByKey(key); if (elem === null) { break; } const {contains, element} = elementContainingEventLocation( anchorElem, elem, eventLocation, ); if (contains.result) { blockElem = element; prevIndex = index; break; } if (direction === Indeterminate) { if (contains.reason.isOnTopSide) { direction = Upward; } else if (contains.reason.isOnBottomSide) { direction = Downward; } else { // stop search block element direction = Infinity; } } index += direction; } }); return blockElem; } function isOnMenu(element: HTMLElement): boolean { return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`); } function setMenuPosition( targetElem: HTMLElement | null, floatingElem: HTMLElement, anchorElem: HTMLElement, ) { if (!targetElem) { floatingElem.style.opacity = '0'; return; } const targetRect = targetElem.getBoundingClientRect(); const targetStyle = window.getComputedStyle(targetElem); const floatingElemRect = floatingElem.getBoundingClientRect(); const anchorElementRect = anchorElem.getBoundingClientRect(); const top = targetRect.top + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - anchorElementRect.top; const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE; floatingElem.style.opacity = '1'; floatingElem.style.transform = `translate(${left}px, ${top}px)`; } function setDragImage( dataTransfer: DataTransfer, draggableBlockElem: HTMLElement, ) { const {transform} = draggableBlockElem.style; // Remove dragImage borders draggableBlockElem.style.transform = 'translateZ(0)'; dataTransfer.setDragImage(draggableBlockElem, 0, 0); setTimeout(() => { draggableBlockElem.style.transform = transform; }); } function setTargetLine( targetLineElem: HTMLElement, targetBlockElem: HTMLElement, mouseY: number, anchorElem: HTMLElement, ) { const targetStyle = window.getComputedStyle(targetBlockElem); const {top: targetBlockElemTop, height: targetBlockElemHeight} = targetBlockElem.getBoundingClientRect(); const {top: anchorTop, width: anchorWidth} = anchorElem.getBoundingClientRect(); let lineTop = targetBlockElemTop; // At the bottom of the target if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) { lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom); } else { lineTop -= parseFloat(targetStyle.marginTop); } const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE; targetLineElem.style.transform = `translate(${left}px, ${top}px)`; targetLineElem.style.width = `${ anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2 }px`; targetLineElem.style.opacity = '.6'; } function hideTargetLine(targetLineElem: HTMLElement | null) { if (targetLineElem) { targetLineElem.style.opacity = '0'; } } function useDraggableBlockMenu( editor: LexicalEditor, anchorElem: HTMLElement, isEditable: boolean, ): JSX.Element { const scrollerElem = anchorElem.parentElement; const menuRef = useRef(null); const targetLineRef = useRef(null); const [draggableBlockElem, setDraggableBlockElem] = useState(null); const dragDataRef = useRef(null); useEffect(() => { function onMouseMove(event: MouseEvent) { const target = event.target; if (!isHTMLElement(target)) { setDraggableBlockElem(null); return; } if (isOnMenu(target)) { return; } const _draggableBlockElem = getBlockElement( anchorElem, editor, new Point(event.clientX, event.clientY), ); setDraggableBlockElem(_draggableBlockElem); } function onMouseLeave() { setDraggableBlockElem(null); } scrollerElem?.addEventListener('mousemove', onMouseMove); scrollerElem?.addEventListener('mouseleave', onMouseLeave); return () => { scrollerElem?.removeEventListener('mousemove', onMouseMove); scrollerElem?.removeEventListener('mouseleave', onMouseLeave); }; }, [scrollerElem, anchorElem, editor]); useEffect(() => { if (menuRef.current) { setMenuPosition(draggableBlockElem, menuRef.current, anchorElem); } }, [anchorElem, draggableBlockElem]); const insertDraggedNode = useCallback( ( draggedNode: LexicalNode, targetNode: LexicalNode, targetBlockElem: HTMLElement, pageY: number, ) => { let nodeToInsert = draggedNode; const targetParent = targetNode.getParent(); const sourceParent = draggedNode.getParent(); if ($isListNode(sourceParent) && !$isListNode(targetParent)) { const newList = $createListNode(sourceParent.getListType()); newList.append(draggedNode); nodeToInsert = newList; } const {top, height} = targetBlockElem.getBoundingClientRect(); const shouldInsertAfter = pageY - top > height / 2; if (shouldInsertAfter) { targetNode.insertAfter(nodeToInsert); } else { targetNode.insertBefore(nodeToInsert); } }, [], ); useEffect(() => { function onDragover(event: DragEvent): boolean { const [isFileTransfer] = eventFiles(event); if (isFileTransfer) { return false; } const {pageY, target} = event; if (!isHTMLElement(target)) { return false; } const targetBlockElem = getBlockElement( anchorElem, editor, new Point(event.pageX, pageY), ); const targetLineElem = targetLineRef.current; if (targetBlockElem === null || targetLineElem === null) { return false; } setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); // Prevent default event to be able to trigger onDrop events event.preventDefault(); return true; } function onDrop(event: DragEvent): boolean { const [isFileTransfer] = eventFiles(event); if (isFileTransfer) { return false; } const {target, dataTransfer, pageY} = event; if (!isHTMLElement(target)) { return false; } const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''; const draggedNode = $getNodeByKey(dragData); if (!draggedNode) { return false; } const targetBlockElem = getBlockElement( anchorElem, editor, new Point(event.pageX, pageY), ); if (!targetBlockElem) { return false; } const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); if (!targetNode) { return false; } if (targetNode === draggedNode) { return true; } insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY); setDraggableBlockElem(null); return true; } return mergeRegister( editor.registerCommand( DRAGOVER_COMMAND, (event) => { return onDragover(event); }, COMMAND_PRIORITY_LOW, ), editor.registerCommand( DROP_COMMAND, (event) => { return onDrop(event); }, COMMAND_PRIORITY_HIGH, ), ); }, [anchorElem, editor, insertDraggedNode]); function onDragStart(event: ReactDragEvent): void { const dataTransfer = event.dataTransfer; if (!dataTransfer || !draggableBlockElem) { return; } setDragImage(dataTransfer, draggableBlockElem); let nodeKey = ''; editor.update(() => { const node = $getNearestNodeFromDOMNode(draggableBlockElem); if (node) { nodeKey = node.getKey(); } }); dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); } function onDragEnd(): void { hideTargetLine(targetLineRef.current); } function onTouchStart(): void { if (!draggableBlockElem) { return; } editor.update(() => { const node = $getNearestNodeFromDOMNode(draggableBlockElem); if (!node) { return; } const nodeKey = node.getKey(); dragDataRef.current = nodeKey; }); } function onTouchMove(event: TouchEvent) { const {pageX, pageY} = event.targetTouches[0]; const rootElement = editor.getRootElement(); if (rootElement) { const {top, bottom} = rootElement.getBoundingClientRect(); const scrollOffset = 20; if (pageY - top < scrollOffset) { rootElement.scrollTop -= scrollOffset; } else if (bottom - pageY < scrollOffset) { rootElement.scrollTop += scrollOffset; } } const targetBlockElem = getBlockElement( anchorElem, editor, new Point(pageX, pageY), ); const targetLineElem = targetLineRef.current; if (targetBlockElem === null || targetLineElem === null) { return; } setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); } function onTouchEnd(event: TouchEvent): void { hideTargetLine(targetLineRef.current); editor.update(() => { const {pageX, pageY} = event.changedTouches[0]; const dragData = dragDataRef.current || ''; const draggedNode = $getNodeByKey(dragData); if (!draggedNode) { return; } const targetBlockElem = getBlockElement( anchorElem, editor, new Point(pageX, pageY), ); if (!targetBlockElem) { return; } const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); if (!targetNode) { return; } if (targetNode === draggedNode) { return; } insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY); }); setDraggableBlockElem(null); } return createPortal( <>
, anchorElem, ); } export default function DraggableBlockPlugin({ anchorElem = document.body, }: { anchorElem?: HTMLElement; }): JSX.Element { const [editor] = useLexicalComposerContext(); return useDraggableBlockMenu(editor, anchorElem, editor._editable); }