From bd26794d34a0b81263d422b544d7c979972c468d Mon Sep 17 00:00:00 2001 From: Sherry Date: Wed, 17 Jul 2024 14:16:06 +0800 Subject: [PATCH] [lexical-react][lexical-playground] sync draggable block plugin to www (#6397) --- .flowconfig | 1 + packages/lexical-devtools/tsconfig.json | 3 + .../plugins/DraggableBlockPlugin/index.tsx | 438 +---------------- .../flow/LexicalDraggableBlockPlugin.js.flow | 10 + packages/lexical-react/package.json | 30 ++ .../src/LexicalDraggableBlockPlugin.tsx | 456 ++++++++++++++++++ .../src/shared}/point.ts | 0 .../src/shared}/rect.ts | 30 +- tsconfig.build.json | 3 + tsconfig.json | 3 + 10 files changed, 543 insertions(+), 431 deletions(-) create mode 100644 packages/lexical-react/flow/LexicalDraggableBlockPlugin.js.flow create mode 100644 packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx rename packages/{lexical-playground/src/utils => lexical-react/src/shared}/point.ts (100%) rename packages/{lexical-playground/src/utils => lexical-react/src/shared}/rect.ts (81%) diff --git a/.flowconfig b/.flowconfig index c0e6df641cb..8d966996cb3 100644 --- a/.flowconfig +++ b/.flowconfig @@ -52,6 +52,7 @@ module.name_mapper='^@lexical/react/LexicalComposerContext$' -> '/ module.name_mapper='^@lexical/react/LexicalContentEditable$' -> '/packages/lexical-react/flow/LexicalContentEditable.js.flow' module.name_mapper='^@lexical/react/LexicalContextMenuPlugin$' -> '/packages/lexical-react/flow/LexicalContextMenuPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalDecoratorBlockNode$' -> '/packages/lexical-react/flow/LexicalDecoratorBlockNode.js.flow' +module.name_mapper='^@lexical/react/LexicalDraggableBlockPlugin$' -> '/packages/lexical-react/flow/LexicalDraggableBlockPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalEditorRefPlugin$' -> '/packages/lexical-react/flow/LexicalEditorRefPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalErrorBoundary$' -> '/packages/lexical-react/flow/LexicalErrorBoundary.js.flow' module.name_mapper='^@lexical/react/LexicalHashtagPlugin$' -> '/packages/lexical-react/flow/LexicalHashtagPlugin.js.flow' diff --git a/packages/lexical-devtools/tsconfig.json b/packages/lexical-devtools/tsconfig.json index b82d880249d..a6fd399e833 100644 --- a/packages/lexical-devtools/tsconfig.json +++ b/packages/lexical-devtools/tsconfig.json @@ -72,6 +72,9 @@ "@lexical/react/LexicalDecoratorBlockNode": [ "../lexical-react/src/LexicalDecoratorBlockNode.ts" ], + "@lexical/react/LexicalDraggableBlockPlugin": [ + "../lexical-react/src/LexicalDraggableBlockPlugin.tsx" + ], "@lexical/react/LexicalEditorRefPlugin": [ "../lexical-react/src/LexicalEditorRefPlugin.tsx" ], diff --git a/packages/lexical-playground/src/plugins/DraggableBlockPlugin/index.tsx b/packages/lexical-playground/src/plugins/DraggableBlockPlugin/index.tsx index 9eb3b2acf16..3675cb786e2 100644 --- a/packages/lexical-playground/src/plugins/DraggableBlockPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/DraggableBlockPlugin/index.tsx @@ -7,431 +7,37 @@ */ import './index.css'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {eventFiles} from '@lexical/rich-text'; -import {calculateZoomLevel, mergeRegister} from '@lexical/utils'; -import { - $getNearestNodeFromDOMNode, - $getNodeByKey, - $getRoot, - COMMAND_PRIORITY_HIGH, - COMMAND_PRIORITY_LOW, - DRAGOVER_COMMAND, - DROP_COMMAND, - LexicalEditor, -} from 'lexical'; -import * as React from 'react'; -import {DragEvent as ReactDragEvent, useEffect, useRef, useState} from 'react'; -import {createPortal} from 'react-dom'; +import {DraggableBlockPlugin_EXPERIMENTAL} from '@lexical/react/LexicalDraggableBlockPlugin'; +import {useRef} from 'react'; -import {isHTMLElement} from '../../utils/guard'; -import {Point} from '../../utils/point'; -import {Rect} from '../../utils/rect'; - -const SPACE = 4; -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 = 28; - -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 getCollapsedMargins(elem: HTMLElement): { - marginTop: number; - marginBottom: number; -} { - const getMargin = ( - element: Element | null, - margin: 'marginTop' | 'marginBottom', - ): number => - element ? parseFloat(window.getComputedStyle(element)[margin]) : 0; - - const {marginTop, marginBottom} = window.getComputedStyle(elem); - const prevElemSiblingMarginBottom = getMargin( - elem.previousElementSibling, - 'marginBottom', - ); - const nextElemSiblingMarginTop = getMargin( - elem.nextElementSibling, - 'marginTop', - ); - const collapsedTopMargin = Math.max( - parseFloat(marginTop), - prevElemSiblingMarginBottom, - ); - const collapsedBottomMargin = Math.max( - parseFloat(marginBottom), - nextElemSiblingMarginTop, - ); - - return {marginBottom: collapsedBottomMargin, marginTop: collapsedTopMargin}; -} - -function getBlockElement( - anchorElem: HTMLElement, - editor: LexicalEditor, - event: MouseEvent, - useEdgeAsDefault = false, -): HTMLElement | null { - const anchorElementRect = anchorElem.getBoundingClientRect(); - const topLevelNodeKeys = getTopLevelNodeKeys(editor); - - let blockElem: HTMLElement | null = null; - - editor.getEditorState().read(() => { - if (useEdgeAsDefault) { - const [firstNode, lastNode] = [ - editor.getElementByKey(topLevelNodeKeys[0]), - editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]), - ]; - - const [firstNodeRect, lastNodeRect] = [ - firstNode?.getBoundingClientRect(), - lastNode?.getBoundingClientRect(), - ]; - - if (firstNodeRect && lastNodeRect) { - const firstNodeZoom = calculateZoomLevel(firstNode); - const lastNodeZoom = calculateZoomLevel(lastNode); - if (event.y / firstNodeZoom < firstNodeRect.top) { - blockElem = firstNode; - } else if (event.y / lastNodeZoom > lastNodeRect.bottom) { - blockElem = lastNode; - } - - if (blockElem) { - return; - } - } - } - - 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 zoom = calculateZoomLevel(elem); - const point = new Point(event.x / zoom, event.y / zoom); - const domRect = Rect.fromDOM(elem); - const {marginTop, marginBottom} = getCollapsedMargins(elem); - const rect = domRect.generateNewRect({ - bottom: domRect.bottom + marginBottom, - left: anchorElementRect.left, - right: anchorElementRect.right, - top: domRect.top - marginTop, - }); - - const { - result, - reason: {isOnTopSide, isOnBottomSide}, - } = rect.contains(point); - - if (result) { - blockElem = elem; - prevIndex = index; - break; - } - - if (direction === Indeterminate) { - if (isOnTopSide) { - direction = Upward; - } else if (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'; - floatingElem.style.transform = 'translate(-10000px, -10000px)'; - 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 = 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 {top: targetBlockElemTop, height: targetBlockElemHeight} = - targetBlockElem.getBoundingClientRect(); - const {top: anchorTop, width: anchorWidth} = - anchorElem.getBoundingClientRect(); - const {marginTop, marginBottom} = getCollapsedMargins(targetBlockElem); - let lineTop = targetBlockElemTop; - if (mouseY >= targetBlockElemTop) { - lineTop += targetBlockElemHeight + marginBottom / 2; - } else { - lineTop -= marginTop / 2; - } - - const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; - const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE; - - targetLineElem.style.transform = `translate(${left}px, ${top}px)`; - targetLineElem.style.width = `${ - anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2 - }px`; - targetLineElem.style.opacity = '.4'; -} - -function hideTargetLine(targetLineElem: HTMLElement | null) { - if (targetLineElem) { - targetLineElem.style.opacity = '0'; - targetLineElem.style.transform = 'translate(-10000px, -10000px)'; - } -} - -function useDraggableBlockMenu( - editor: LexicalEditor, - anchorElem: HTMLElement, - isEditable: boolean, -): JSX.Element { - const scrollerElem = anchorElem.parentElement; - - const menuRef = useRef(null); - const targetLineRef = useRef(null); - const isDraggingBlockRef = useRef(false); - const [draggableBlockElem, setDraggableBlockElem] = - useState(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, event); - - 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]); - - useEffect(() => { - function onDragover(event: DragEvent): boolean { - if (!isDraggingBlockRef.current) { - return false; - } - const [isFileTransfer] = eventFiles(event); - if (isFileTransfer) { - return false; - } - const {pageY, target} = event; - if (!isHTMLElement(target)) { - return false; - } - const targetBlockElem = getBlockElement(anchorElem, editor, event, true); - const targetLineElem = targetLineRef.current; - if (targetBlockElem === null || targetLineElem === null) { - return false; - } - setTargetLine( - targetLineElem, - targetBlockElem, - pageY / calculateZoomLevel(target), - anchorElem, - ); - // Prevent default event to be able to trigger onDrop events - event.preventDefault(); - return true; - } - - function $onDrop(event: DragEvent): boolean { - if (!isDraggingBlockRef.current) { - return false; - } - const [isFileTransfer] = eventFiles(event); - if (isFileTransfer) { - return false; - } - const {target, dataTransfer, pageY} = event; - const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''; - const draggedNode = $getNodeByKey(dragData); - if (!draggedNode) { - return false; - } - if (!isHTMLElement(target)) { - return false; - } - const targetBlockElem = getBlockElement(anchorElem, editor, event, true); - if (!targetBlockElem) { - return false; - } - const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); - if (!targetNode) { - return false; - } - if (targetNode === draggedNode) { - return true; - } - const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top; - if (pageY / calculateZoomLevel(target) >= targetBlockElemTop) { - targetNode.insertAfter(draggedNode); - } else { - targetNode.insertBefore(draggedNode); - } - 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]); - - 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(); - } - }); - isDraggingBlockRef.current = true; - dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); - } - - function onDragEnd(): void { - isDraggingBlockRef.current = false; - hideTargetLine(targetLineRef.current); - } - - return createPortal( - <> -
-
-
-
- , - anchorElem, - ); -} - -export default function DraggableBlockPlugin({ +export default function PlaygroundDraggableBlockPlugin({ anchorElem = document.body, }: { anchorElem?: HTMLElement; }): JSX.Element { - const [editor] = useLexicalComposerContext(); - return useDraggableBlockMenu(editor, anchorElem, editor._editable); + const menuRef = useRef(null); + const targetLineRef = useRef(null); + + return ( + +
+
+ } + targetLineComponent={ +
+ } + isOnMenu={isOnMenu} + /> + ); } diff --git a/packages/lexical-react/flow/LexicalDraggableBlockPlugin.js.flow b/packages/lexical-react/flow/LexicalDraggableBlockPlugin.js.flow new file mode 100644 index 00000000000..ef306bb38a6 --- /dev/null +++ b/packages/lexical-react/flow/LexicalDraggableBlockPlugin.js.flow @@ -0,0 +1,10 @@ +/** + * 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. + * + * @flow strict + */ + +declare export function DraggableBlockPlugin(): React$MixedElement; diff --git a/packages/lexical-react/package.json b/packages/lexical-react/package.json index 005a91755b8..a95ba4ed46a 100644 --- a/packages/lexical-react/package.json +++ b/packages/lexical-react/package.json @@ -491,6 +491,36 @@ "default": "./LexicalDecoratorBlockNode.js" } }, + "./LexicalDraggableBlockPlugin": { + "import": { + "types": "./LexicalDraggableBlockPlugin.d.ts", + "development": "./LexicalDraggableBlockPlugin.dev.mjs", + "production": "./LexicalDraggableBlockPlugin.prod.mjs", + "node": "./LexicalDraggableBlockPlugin.node.mjs", + "default": "./LexicalDraggableBlockPlugin.mjs" + }, + "require": { + "types": "./LexicalDraggableBlockPlugin.d.ts", + "development": "./LexicalDraggableBlockPlugin.dev.js", + "production": "./LexicalDraggableBlockPlugin.prod.js", + "default": "./LexicalDraggableBlockPlugin.js" + } + }, + "./LexicalDraggableBlockPlugin.js": { + "import": { + "types": "./LexicalDraggableBlockPlugin.d.ts", + "development": "./LexicalDraggableBlockPlugin.dev.mjs", + "production": "./LexicalDraggableBlockPlugin.prod.mjs", + "node": "./LexicalDraggableBlockPlugin.node.mjs", + "default": "./LexicalDraggableBlockPlugin.mjs" + }, + "require": { + "types": "./LexicalDraggableBlockPlugin.d.ts", + "development": "./LexicalDraggableBlockPlugin.dev.js", + "production": "./LexicalDraggableBlockPlugin.prod.js", + "default": "./LexicalDraggableBlockPlugin.js" + } + }, "./LexicalEditorRefPlugin": { "import": { "types": "./LexicalEditorRefPlugin.d.ts", diff --git a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx new file mode 100644 index 00000000000..d37bd69f3c9 --- /dev/null +++ b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx @@ -0,0 +1,456 @@ +/** + * 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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {eventFiles} from '@lexical/rich-text'; +import {calculateZoomLevel, isHTMLElement, mergeRegister} from '@lexical/utils'; +import { + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + DRAGOVER_COMMAND, + DROP_COMMAND, + LexicalEditor, +} from 'lexical'; +import { + DragEvent as ReactDragEvent, + ReactNode, + useEffect, + useRef, + useState, +} from 'react'; +import {createPortal} from 'react-dom'; + +import {Point} from './shared/point'; +import {Rectangle} from './shared/rect'; + +const SPACE = 4; +const TARGET_LINE_HALF_HEIGHT = 2; +const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'; +const TEXT_BOX_HORIZONTAL_PADDING = 28; + +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 getCollapsedMargins(elem: HTMLElement): { + marginTop: number; + marginBottom: number; +} { + const getMargin = ( + element: Element | null, + margin: 'marginTop' | 'marginBottom', + ): number => + element ? parseFloat(window.getComputedStyle(element)[margin]) : 0; + + const {marginTop, marginBottom} = window.getComputedStyle(elem); + const prevElemSiblingMarginBottom = getMargin( + elem.previousElementSibling, + 'marginBottom', + ); + const nextElemSiblingMarginTop = getMargin( + elem.nextElementSibling, + 'marginTop', + ); + const collapsedTopMargin = Math.max( + parseFloat(marginTop), + prevElemSiblingMarginBottom, + ); + const collapsedBottomMargin = Math.max( + parseFloat(marginBottom), + nextElemSiblingMarginTop, + ); + + return {marginBottom: collapsedBottomMargin, marginTop: collapsedTopMargin}; +} + +function getBlockElement( + anchorElem: HTMLElement, + editor: LexicalEditor, + event: MouseEvent, + useEdgeAsDefault = false, +): HTMLElement | null { + const anchorElementRect = anchorElem.getBoundingClientRect(); + const topLevelNodeKeys = getTopLevelNodeKeys(editor); + + let blockElem: HTMLElement | null = null; + + editor.getEditorState().read(() => { + if (useEdgeAsDefault) { + const [firstNode, lastNode] = [ + editor.getElementByKey(topLevelNodeKeys[0]), + editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]), + ]; + + const [firstNodeRect, lastNodeRect] = [ + firstNode != null ? firstNode.getBoundingClientRect() : undefined, + lastNode != null ? lastNode.getBoundingClientRect() : undefined, + ]; + + if (firstNodeRect && lastNodeRect) { + const firstNodeZoom = calculateZoomLevel(firstNode); + const lastNodeZoom = calculateZoomLevel(lastNode); + if (event.y / firstNodeZoom < firstNodeRect.top) { + blockElem = firstNode; + } else if (event.y / lastNodeZoom > lastNodeRect.bottom) { + blockElem = lastNode; + } + + if (blockElem) { + return; + } + } + } + + 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 zoom = calculateZoomLevel(elem); + const point = new Point(event.x / zoom, event.y / zoom); + const domRect = Rectangle.fromDOM(elem); + const {marginTop, marginBottom} = getCollapsedMargins(elem); + const rect = domRect.generateNewRect({ + bottom: domRect.bottom + marginBottom, + left: anchorElementRect.left, + right: anchorElementRect.right, + top: domRect.top - marginTop, + }); + + const { + result, + reason: {isOnTopSide, isOnBottomSide}, + } = rect.contains(point); + + if (result) { + blockElem = elem; + prevIndex = index; + break; + } + + if (direction === Indeterminate) { + if (isOnTopSide) { + direction = Upward; + } else if (isOnBottomSide) { + direction = Downward; + } else { + // stop search block element + direction = Infinity; + } + } + + index += direction; + } + }); + + return blockElem; +} + +function setMenuPosition( + targetElem: HTMLElement | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, +) { + if (!targetElem) { + floatingElem.style.opacity = '0'; + floatingElem.style.transform = 'translate(-10000px, -10000px)'; + 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 = 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 {top: targetBlockElemTop, height: targetBlockElemHeight} = + targetBlockElem.getBoundingClientRect(); + const {top: anchorTop, width: anchorWidth} = + anchorElem.getBoundingClientRect(); + const {marginTop, marginBottom} = getCollapsedMargins(targetBlockElem); + let lineTop = targetBlockElemTop; + if (mouseY >= targetBlockElemTop) { + lineTop += targetBlockElemHeight + marginBottom / 2; + } else { + lineTop -= marginTop / 2; + } + + const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; + const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE; + + targetLineElem.style.transform = `translate(${left}px, ${top}px)`; + targetLineElem.style.width = `${ + anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2 + }px`; + targetLineElem.style.opacity = '.4'; +} + +function hideTargetLine(targetLineElem: HTMLElement | null) { + if (targetLineElem) { + targetLineElem.style.opacity = '0'; + targetLineElem.style.transform = 'translate(-10000px, -10000px)'; + } +} + +function useDraggableBlockMenu( + editor: LexicalEditor, + anchorElem: HTMLElement, + menuRef: React.RefObject, + targetLineRef: React.RefObject, + isEditable: boolean, + menuComponent: ReactNode, + targetLineComponent: ReactNode, + isOnMenu: (element: HTMLElement) => boolean, +): JSX.Element { + const scrollerElem = anchorElem.parentElement; + + const isDraggingBlockRef = useRef(false); + const [draggableBlockElem, setDraggableBlockElem] = + useState(null); + + useEffect(() => { + function onMouseMove(event: MouseEvent) { + const target = event.target; + if (target != null && !isHTMLElement(target)) { + setDraggableBlockElem(null); + return; + } + + if (target != null && isOnMenu(target as HTMLElement)) { + return; + } + + const _draggableBlockElem = getBlockElement(anchorElem, editor, event); + + setDraggableBlockElem(_draggableBlockElem); + } + + function onMouseLeave() { + setDraggableBlockElem(null); + } + + if (scrollerElem != null) { + scrollerElem.addEventListener('mousemove', onMouseMove); + scrollerElem.addEventListener('mouseleave', onMouseLeave); + } + + return () => { + if (scrollerElem != null) { + scrollerElem.removeEventListener('mousemove', onMouseMove); + scrollerElem.removeEventListener('mouseleave', onMouseLeave); + } + }; + }, [scrollerElem, anchorElem, editor, isOnMenu]); + + useEffect(() => { + if (menuRef.current) { + setMenuPosition(draggableBlockElem, menuRef.current, anchorElem); + } + }, [anchorElem, draggableBlockElem, menuRef]); + + useEffect(() => { + function onDragover(event: DragEvent): boolean { + if (!isDraggingBlockRef.current) { + return false; + } + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const {pageY, target} = event; + if (target != null && !isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event, true); + const targetLineElem = targetLineRef.current; + if (targetBlockElem === null || targetLineElem === null) { + return false; + } + setTargetLine( + targetLineElem, + targetBlockElem, + pageY / calculateZoomLevel(target), + anchorElem, + ); + // Prevent default event to be able to trigger onDrop events + event.preventDefault(); + return true; + } + + function $onDrop(event: DragEvent): boolean { + if (!isDraggingBlockRef.current) { + return false; + } + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const {target, dataTransfer, pageY} = event; + const dragData = + dataTransfer != null ? dataTransfer.getData(DRAG_DATA_FORMAT) : ''; + const draggedNode = $getNodeByKey(dragData); + if (!draggedNode) { + return false; + } + if (target != null && !isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event, true); + if (!targetBlockElem) { + return false; + } + const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); + if (!targetNode) { + return false; + } + if (targetNode === draggedNode) { + return true; + } + const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top; + if (pageY / calculateZoomLevel(target) >= targetBlockElemTop) { + targetNode.insertAfter(draggedNode); + } else { + targetNode.insertBefore(draggedNode); + } + 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, targetLineRef]); + + 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(); + } + }); + isDraggingBlockRef.current = true; + dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); + } + + function onDragEnd(): void { + isDraggingBlockRef.current = false; + hideTargetLine(targetLineRef.current); + } + return createPortal( + <> +
+ {isEditable && menuComponent} +
+ {targetLineComponent} + , + anchorElem, + ); +} + +export function DraggableBlockPlugin_EXPERIMENTAL({ + anchorElem = document.body, + menuRef, + targetLineRef, + menuComponent, + targetLineComponent, + isOnMenu, +}: { + anchorElem?: HTMLElement; + menuRef: React.RefObject; + targetLineRef: React.RefObject; + menuComponent: ReactNode; + targetLineComponent: ReactNode; + isOnMenu: (element: HTMLElement) => boolean; +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + return useDraggableBlockMenu( + editor, + anchorElem, + menuRef, + targetLineRef, + editor._editable, + menuComponent, + targetLineComponent, + isOnMenu, + ); +} diff --git a/packages/lexical-playground/src/utils/point.ts b/packages/lexical-react/src/shared/point.ts similarity index 100% rename from packages/lexical-playground/src/utils/point.ts rename to packages/lexical-react/src/shared/point.ts diff --git a/packages/lexical-playground/src/utils/rect.ts b/packages/lexical-react/src/shared/rect.ts similarity index 81% rename from packages/lexical-playground/src/utils/rect.ts rename to packages/lexical-react/src/shared/rect.ts index be118c35e2a..2118352c845 100644 --- a/packages/lexical-playground/src/utils/rect.ts +++ b/packages/lexical-react/src/shared/rect.ts @@ -17,7 +17,7 @@ type ContainsPointReturn = { }; }; -export class Rect { +export class Rectangle { private readonly _left: number; private readonly _top: number; private readonly _right: number; @@ -60,7 +60,7 @@ export class Rect { return Math.abs(this._bottom - this._top); } - public equals({top, left, bottom, right}: Rect): boolean { + public equals({top, left, bottom, right}: Rectangle): boolean { return ( top === this._top && bottom === this._bottom && @@ -70,8 +70,8 @@ export class Rect { } public contains({x, y}: Point): ContainsPointReturn; - public contains({top, left, bottom, right}: Rect): boolean; - public contains(target: Point | Rect): boolean | ContainsPointReturn { + public contains({top, left, bottom, right}: Rectangle): boolean; + public contains(target: Point | Rectangle): boolean | ContainsPointReturn { if (isPoint(target)) { const {x, y} = target; @@ -108,7 +108,7 @@ export class Rect { } } - public intersectsWith(rect: Rect): boolean { + public intersectsWith(rect: Rectangle): boolean { const {left: x1, top: y1, width: w1, height: h1} = rect; const {left: x2, top: y2, width: w2, height: h2} = this; const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2; @@ -123,8 +123,8 @@ export class Rect { top = this.top, right = this.right, bottom = this.bottom, - }): Rect { - return new Rect(left, top, right, bottom); + }): Rectangle { + return new Rectangle(left, top, right, bottom); } static fromLTRB( @@ -132,8 +132,8 @@ export class Rect { top: number, right: number, bottom: number, - ): Rect { - return new Rect(left, top, right, bottom); + ): Rectangle { + return new Rectangle(left, top, right, bottom); } static fromLWTH( @@ -141,18 +141,18 @@ export class Rect { width: number, top: number, height: number, - ): Rect { - return new Rect(left, top, left + width, top + height); + ): Rectangle { + return new Rectangle(left, top, left + width, top + height); } - static fromPoints(startPoint: Point, endPoint: Point): Rect { + static fromPoints(startPoint: Point, endPoint: Point): Rectangle { const {y: top, x: left} = startPoint; const {y: bottom, x: right} = endPoint; - return Rect.fromLTRB(left, top, right, bottom); + return Rectangle.fromLTRB(left, top, right, bottom); } - static fromDOM(dom: HTMLElement): Rect { + static fromDOM(dom: HTMLElement): Rectangle { const {top, width, left, height} = dom.getBoundingClientRect(); - return Rect.fromLWTH(left, width, top, height); + return Rectangle.fromLWTH(left, width, top, height); } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 40b574094d5..febc18a834a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -73,6 +73,9 @@ "@lexical/react/LexicalDecoratorBlockNode": [ "./packages/lexical-react/src/LexicalDecoratorBlockNode.ts" ], + "@lexical/react/LexicalDraggableBlockPlugin": [ + "./packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx" + ], "@lexical/react/LexicalEditorRefPlugin": [ "./packages/lexical-react/src/LexicalEditorRefPlugin.tsx" ], diff --git a/tsconfig.json b/tsconfig.json index 7ec705788c3..a090e0449b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -81,6 +81,9 @@ "@lexical/react/LexicalDecoratorBlockNode": [ "./packages/lexical-react/src/LexicalDecoratorBlockNode.ts" ], + "@lexical/react/LexicalDraggableBlockPlugin": [ + "./packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx" + ], "@lexical/react/LexicalEditorRefPlugin": [ "./packages/lexical-react/src/LexicalEditorRefPlugin.tsx" ],