Add draggable block plugin

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-03-14 17:22:23 +01:00
parent 9ce079ce7e
commit 8c68e93e55
19 changed files with 632 additions and 40 deletions

View File

@@ -0,0 +1,369 @@
/**
* 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 { mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
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 SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import isHTMLElement from '../utils/is-html-element';
import Point from '../utils/point';
import Rect from '../utils/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;
const getCurrentIndex = (keysLength: number): number => {
if (keysLength === 0) {
return Infinity;
}
if (prevIndex >= 0 && prevIndex < keysLength) {
return prevIndex;
}
return Math.floor(keysLength / 2);
};
const getTopLevelNodeKeys = (editor: LexicalEditor): string[] => editor.getEditorState().read(() => $getRoot().getChildrenKeys());
const getBlockElement = (
anchorElem: HTMLElement,
editor: LexicalEditor,
event: MouseEvent,
): HTMLElement | null => {
const anchorElementRect = anchorElem.getBoundingClientRect();
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 point = new Point(event.x, event.y);
const domRect = Rect.fromDOM(elem);
const { marginTop, marginBottom } = window.getComputedStyle(elem);
const rect = domRect.generateNewRect({
bottom: domRect.bottom + parseFloat(marginBottom),
left: anchorElementRect.left,
right: anchorElementRect.right,
top: domRect.top - parseFloat(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;
};
const isOnMenu = (element: HTMLElement): boolean => !!element.closest('.draggable-block-menu');
const 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)`;
};
const 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;
});
};
const 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 - SPACE;
targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
targetLineElem.style.width = `${
anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2
}px`;
targetLineElem.style.opacity = '.4';
};
const hideTargetLine = (targetLineElem: HTMLElement | null) => {
if (targetLineElem) {
targetLineElem.style.opacity = '0';
targetLineElem.style.transform = 'translate(-10000px, -10000px)';
}
};
const useDraggableBlockMenu = (
editor: LexicalEditor,
anchorElem: HTMLElement,
isEditable: boolean,
): JSX.Element => {
const scrollerElem = anchorElem.parentElement;
const menuRef = useRef<HTMLDivElement>(null);
const targetLineRef = useRef<HTMLDivElement>(null);
const [draggableBlockElem, setDraggableBlockElem] =
useState<HTMLElement | null>(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 {
const [isFileTransfer] = eventFiles(event);
if (isFileTransfer) {
return false;
}
const { pageY, target } = event;
if (!isHTMLElement(target)) {
return false;
}
const targetBlockElem = getBlockElement(anchorElem, editor, event);
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;
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);
if (!targetBlockElem) {
return false;
}
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
if (!targetNode) {
return false;
}
if (targetNode === draggedNode) {
return true;
}
const { top, height } = targetBlockElem.getBoundingClientRect();
const shouldInsertAfter = pageY - top > height / 2;
if (shouldInsertAfter) {
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<HTMLDivElement>): 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);
}
return createPortal(
<>
<div
className='draggable-block-menu absolute top-0 right-0 cursor-grab rounded px-[1px] py-0.5 opacity-0 will-change-transform hover:bg-gray-100 active:cursor-grabbing hover:dark:bg-primary-700'
ref={menuRef}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<SvgIcon
src={require('@tabler/icons/grip-vertical.svg')}
className={clsx(isEditable && 'pointer-events-none h-4 w-4 text-gray-700 dark:text-gray-600')}
/>
</div>
<div className='pointer-events-none absolute left-0 top-0 h-1 bg-secondary-400 opacity-0 will-change-transform' ref={targetLineRef} />
</>,
anchorElem,
);
};
export default function DraggableBlockPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element {
const [editor] = useLexicalComposerContext();
return useDraggableBlockMenu(editor, anchorElem, editor._editable);
}

View File

@@ -161,8 +161,6 @@ function checkForAtSignMentions(
minMatchLength: number,
): QueryMatch | null {
let match = AtSignMentionsRegex.exec(text);
console.log(text, match);
if (match === null) {
match = AtSignMentionsRegexAliasRegex.exec(text);
}
@@ -278,7 +276,6 @@ export function MentionPlugin(): JSX.Element | null {
const checkForMentionMatch = useCallback(
(text: string) => {
console.log(text);
const mentionMatch = getPossibleQueryMatch(text);
const slashMatch = checkForSlashTriggerMatch(text, editor);
return !slashMatch && mentionMatch ? mentionMatch : null;

View File

@@ -125,7 +125,7 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean {
const startOffset = leadOffset;
const endOffset = domSelection.anchorOffset;
if (anchorNode == null || endOffset == null) {
if (!anchorNode || !endOffset) {
return false;
}
@@ -142,9 +142,7 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean {
function getQueryTextForSearch(editor: LexicalEditor): string | null {
let text = null;
editor.getEditorState().read(() => {
console.log(editor.getEditorState().toJSON());
const selection = $getSelection();
console.log(selection);
if (!$isRangeSelection(selection)) {
return;
}
@@ -289,10 +287,10 @@ export function useDynamicPositioning(
) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (targetElement != null && resolution != null) {
if (targetElement && resolution) {
const rootElement = editor.getRootElement();
const rootScrollParent =
rootElement != null
rootElement
? getScrollParent(rootElement, false)
: document.body;
let ticking = false;
@@ -314,7 +312,7 @@ export function useDynamicPositioning(
);
if (isInView !== previousIsInView) {
previousIsInView = isInView;
if (onVisibilityChange != null) {
if (onVisibilityChange) {
onVisibilityChange(isInView);
}
}
@@ -423,7 +421,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
editor.registerCommand(
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
({ option }) => {
if (option.ref && option.ref.current != null) {
if (option.ref && option.ref.current) {
scrollIntoViewIfNeeded(option.ref.current);
return true;
}
@@ -446,7 +444,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0;
updateSelectedIndex(newSelectedIndex);
const option = options[newSelectedIndex];
if (option.ref != null && option.ref.current) {
if (option.ref && option.ref.current) {
editor.dispatchCommand(
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
{
@@ -471,7 +469,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1;
updateSelectedIndex(newSelectedIndex);
const option = options[newSelectedIndex];
if (option.ref != null && option.ref.current) {
if (option.ref && option.ref.current) {
scrollIntoViewIfNeeded(option.ref.current);
}
event.preventDefault();
@@ -499,7 +497,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
if (
options === null ||
selectedIndex === null ||
options[selectedIndex] == null
!options[selectedIndex]
) {
return false;
}
@@ -516,7 +514,7 @@ function LexicalPopoverMenu<TOption extends TypeaheadOption>({
if (
options === null ||
selectedIndex === null ||
options[selectedIndex] == null
!options[selectedIndex]
) {
return false;
}
@@ -612,7 +610,7 @@ function useMenuAnchorRef(
containerDiv.style.width = `${width}px`;
if (!containerDiv.isConnected) {
if (className != null) {
if (className) {
containerDiv.className = className;
}
containerDiv.setAttribute('aria-label', 'Typeahead menu');
@@ -706,7 +704,7 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
const closeTypeahead = useCallback(() => {
setResolution(null);
if (onClose != null && resolution !== null) {
if (onClose && resolution !== null) {
onClose();
}
}, [onClose, resolution]);
@@ -714,7 +712,7 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
const openTypeahead = useCallback(
(res: Resolution) => {
setResolution(res);
if (onOpen != null && resolution === null) {
if (onOpen && resolution === null) {
onOpen(res);
}
},
@@ -821,7 +819,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
const closeNodeMenu = useCallback(() => {
setResolution(null);
if (onClose != null && resolution !== null) {
if (onClose && resolution !== null) {
onClose();
}
}, [onClose, resolution]);
@@ -829,7 +827,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
const openNodeMenu = useCallback(
(res: Resolution) => {
setResolution(res);
if (onOpen != null && resolution === null) {
if (onOpen && resolution === null) {
onOpen(res);
}
},
@@ -841,9 +839,9 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
editor.update(() => {
const node = $getNodeByKey(nodeKey);
const domElement = editor.getElementByKey(nodeKey);
if (node != null && domElement != null) {
if (node && domElement) {
const text = node.getTextContent();
if (resolution == null || resolution.match.matchingString !== text) {
if (!resolution || resolution.match.matchingString !== text) {
startTransition(() =>
openNodeMenu({
getRect: () => domElement.getBoundingClientRect(),
@@ -857,7 +855,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
}
}
});
} else if (nodeKey == null && resolution != null) {
} else if (!nodeKey && resolution) {
closeNodeMenu();
}
}, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
@@ -867,7 +865,7 @@ export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
}, [positionOrCloseMenu, nodeKey]);
useEffect(() => {
if (nodeKey != null) {
if (nodeKey) {
return editor.registerUpdateListener(({ dirtyElements }) => {
if (dirtyElements.get(nodeKey)) {
positionOrCloseMenu();